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

lxy-9602 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 a9718292 perf(manifest): selectively decode bucket entries with 
bounded Avro indexes (#323)
a9718292 is described below

commit a9718292802df0f80fd591d0ffa2b1e357f7d49c
Author: gripleaf <[email protected]>
AuthorDate: Tue Sep 15 20:49:42 2026 +0800

    perf(manifest): selectively decode bucket entries with bounded Avro indexes 
(#323)
---
 src/paimon/core/manifest/manifest_file.cpp         |  88 ++++-
 src/paimon/core/manifest/manifest_file.h           |   9 +-
 src/paimon/core/manifest/manifest_file_test.cpp    | 392 +++++++++++++++++----
 .../operation/append_only_file_store_scan_test.cpp | 344 +++++++++++++++++-
 src/paimon/core/operation/file_store_scan.cpp      |  59 ++--
 src/paimon/core/operation/file_store_scan.h        |   3 -
 src/paimon/core/snapshot_file_scan.cpp             |   9 +-
 src/paimon/core/snapshot_file_scan_test.cpp        |  35 ++
 src/paimon/core/utils/objects_file.h               |  17 +-
 src/paimon/format/avro/avro_direct_decoder.cpp     |   4 +
 src/paimon/format/avro/avro_direct_decoder.h       |   3 +
 src/paimon/format/avro/avro_file_batch_reader.cpp  | 148 +++++++-
 src/paimon/format/avro/avro_file_batch_reader.h    |  63 +++-
 .../format/avro/avro_file_batch_reader_test.cpp    | 367 +++++++++++++++++++
 test/inte/scan_and_read_inte_test.cpp              | 299 ++++++++++++++++
 15 files changed, 1682 insertions(+), 158 deletions(-)

diff --git a/src/paimon/core/manifest/manifest_file.cpp 
b/src/paimon/core/manifest/manifest_file.cpp
index ecbcb6bd..d525f62a 100644
--- a/src/paimon/core/manifest/manifest_file.cpp
+++ b/src/paimon/core/manifest/manifest_file.cpp
@@ -26,7 +26,10 @@
 #include "arrow/c/abi.h"
 #include "arrow/c/bridge.h"
 #include "paimon/common/data/columnar/columnar_row.h"
+#include "paimon/common/predicate/predicate_validator.h"
+#include "paimon/common/reader/late_materializing_file_batch_reader.h"
 #include "paimon/common/utils/arrow/status_utils.h"
+#include "paimon/common/utils/checked_cast.h"
 #include "paimon/core/io/rolling_file_writer.h"
 #include "paimon/core/manifest/manifest_entry.h"
 #include "paimon/core/manifest/manifest_entry_serializer.h"
@@ -39,6 +42,7 @@
 #include "paimon/format/file_format.h"
 #include "paimon/format/reader_builder.h"
 #include "paimon/format/writer_builder.h"
+#include "paimon/predicate/predicate_builder.h"
 #include "paimon/status.h"
 
 namespace arrow {
@@ -49,6 +53,12 @@ class Schema;
 namespace paimon {
 class MemoryPool;
 
+namespace {
+constexpr int32_t kVersionFieldIndex = 0;
+constexpr int32_t kBucketFieldIndex = 3;
+constexpr int32_t kTotalBucketsFieldIndex = 4;
+}  // namespace
+
 ManifestFile::ManifestFile(const std::shared_ptr<FileSystem>& file_system,
                            const std::shared_ptr<ReaderBuilder>& 
reader_builder,
                            const std::shared_ptr<WriterBuilder>& 
writer_builder,
@@ -91,17 +101,26 @@ Result<std::unique_ptr<ManifestFile>> ManifestFile::Create(
 }
 
 Status ManifestFile::ReadBucketEntries(const std::string& file_name, int32_t 
bucket,
+                                       const std::optional<int32_t>& 
expected_total_buckets,
                                        std::optional<int64_t> file_size,
                                        std::vector<ManifestEntry>* entries) 
const {
+    // Readers without precise bitmap selection still filter aligned Arrow 
columns
+    // before constructing ManifestEntry and DataFileMeta objects.
     return ReadArrowBatches(
-        file_name,
-        [this, bucket, entries](const std::shared_ptr<arrow::StructArray>& 
batch) -> Status {
-            const arrow::ArrayVector& fields = batch->fields();
-            ColumnarRow row(fields, pool_, /*row_id=*/0);
-            for (int64_t i = 0; i < batch->length(); i++) {
+        file_name, file_size,
+        [this, bucket, expected_total_buckets,
+         entries](const std::shared_ptr<arrow::StructArray>& batch) -> Status {
+            ColumnarRow row(batch->fields(), pool_, /*row_id=*/0);
+            for (int64_t i = 0; i < batch->length(); ++i) {
                 row.SetRowId(i);
-                
PAIMON_RETURN_NOT_OK(ManifestEntrySerializer::ValidateVersion(row.GetInt(0)));
-                if (ManifestEntrySerializer::GetBucket(row) != bucket) {
+                PAIMON_RETURN_NOT_OK(
+                    
ManifestEntrySerializer::ValidateVersion(row.GetInt(kVersionFieldIndex)));
+                // Different or unknown bucket counts must reach per-entry 
bucket filtering.
+                const bool historical_layout =
+                    expected_total_buckets &&
+                    (row.IsNullAt(kBucketFieldIndex) || 
row.IsNullAt(kTotalBucketsFieldIndex) ||
+                     row.GetInt(kTotalBucketsFieldIndex) != 
expected_total_buckets.value());
+                if (!historical_layout && 
ManifestEntrySerializer::GetBucket(row) != bucket) {
                     continue;
                 }
                 PAIMON_ASSIGN_OR_RAISE(ManifestEntry entry, 
serializer_->FromRow(row));
@@ -109,7 +128,60 @@ Status ManifestFile::ReadBucketEntries(const std::string& 
file_name, int32_t buc
             }
             return Status::OK();
         },
-        file_size);
+        [this, bucket, 
expected_total_buckets](std::unique_ptr<FileBatchReader>* reader) {
+            return PrepareBucketRead(bucket, expected_total_buckets, reader);
+        });
+}
+
+Status ManifestFile::PrepareBucketRead(int32_t bucket,
+                                       const std::optional<int32_t>& 
expected_total_buckets,
+                                       std::unique_ptr<FileBatchReader>* 
reader) const {
+    if (!(*reader)->SupportPreciseBitmapSelection()) {
+        return Status::OK();
+    }
+    PAIMON_ASSIGN_OR_RAISE(std::unique_ptr<ArrowSchema> c_schema, 
(*reader)->GetFileSchema());
+    PAIMON_ASSIGN_OR_RAISE_FROM_ARROW(std::shared_ptr<arrow::Schema> 
file_schema,
+                                      arrow::ImportSchema(c_schema.get()));
+    const auto& target_type = serializer_->GetDataType();
+    const std::string& bucket_name = 
target_type->field(kBucketFieldIndex)->name();
+    std::shared_ptr<Predicate> selector =
+        PredicateBuilder::Equal(kBucketFieldIndex, bucket_name, 
FieldType::INT, Literal(bucket));
+    if (expected_total_buckets) {
+        const std::string& total_name = 
target_type->field(kTotalBucketsFieldIndex)->name();
+        PAIMON_ASSIGN_OR_RAISE(
+            selector,
+            PredicateBuilder::Or(
+                {selector,
+                 PredicateBuilder::IsNull(kTotalBucketsFieldIndex, total_name, 
FieldType::INT),
+                 PredicateBuilder::NotEqual(kTotalBucketsFieldIndex, 
total_name, FieldType::INT,
+                                            
Literal(expected_total_buckets.value()))}));
+    }
+    // Retain unsupported versions regardless of bucket so the consumer 
validates every version
+    // before bucket filtering, including when the probe would otherwise 
select no entries.
+    const std::string& version_name = 
target_type->field(kVersionFieldIndex)->name();
+    PAIMON_ASSIGN_OR_RAISE(
+        selector,
+        PredicateBuilder::Or(
+            {selector, PredicateBuilder::IsNull(kVersionFieldIndex, 
version_name, FieldType::INT),
+             PredicateBuilder::NotEqual(
+                 kVersionFieldIndex, version_name, FieldType::INT,
+                 Literal(
+                     
checked_cast<ManifestEntrySerializer*>(serializer_.get())->GetVersion()))}));
+    if (!PredicateValidator::ValidatePredicateWithSchema(*file_schema, 
selector,
+                                                         
/*validate_field_idx=*/true)
+             .ok()) {
+        // An incompatible projection must fall back to ordinary schema 
evolution.
+        return Status::OK();
+    }
+    PAIMON_ASSIGN_OR_RAISE(
+        std::unique_ptr<LateMaterializingFileBatchReader> selective_reader,
+        LateMaterializingFileBatchReader::Create(std::move(*reader), 
arrow_pool_));
+    // Keep the on-disk schema; ManifestMetaReader still performs schema 
evolution afterwards.
+    ArrowSchema full_c_schema;
+    PAIMON_RETURN_NOT_OK_FROM_ARROW(arrow::ExportSchema(*file_schema, 
&full_c_schema));
+    PAIMON_RETURN_NOT_OK(selective_reader->SetReadSchema(&full_c_schema, 
selector, std::nullopt));
+    *reader = std::move(selective_reader);
+    return Status::OK();
 }
 
 Result<std::vector<ManifestFileMeta>> ManifestFile::Write(
diff --git a/src/paimon/core/manifest/manifest_file.h 
b/src/paimon/core/manifest/manifest_file.h
index 2f3a60cb..3840368f 100644
--- a/src/paimon/core/manifest/manifest_file.h
+++ b/src/paimon/core/manifest/manifest_file.h
@@ -63,16 +63,23 @@ class ManifestFile : public ObjectsFile<ManifestEntry> {
     /// @note This method is atomic.
     Result<std::vector<ManifestFileMeta>> Write(const 
std::vector<ManifestEntry>& entries);
 
-    /// Read a manifest file and deserialize only entries for the specified 
bucket.
+    /// Read entries for a bucket. An inferred total bucket count also retains 
entries with
+    /// different or unknown bucket counts for per-entry bucket filtering.
+    /// Bucket pruning assumes stable bucket-key hashing across schema 
versions.
+    /// Serialization versions are validated for all entries before bucket 
filtering.
     ///
     /// @param file_size Length of the manifest when the caller already has it 
from the manifest
     ///                  list, which saves the read a metadata request on a 
remote store. Pass
     ///                  std::nullopt when the length is not known.
     Status ReadBucketEntries(const std::string& file_name, int32_t bucket,
+                             const std::optional<int32_t>& 
expected_total_buckets,
                              std::optional<int64_t> file_size,
                              std::vector<ManifestEntry>* entries) const;
 
  private:
+    Status PrepareBucketRead(int32_t bucket, const std::optional<int32_t>& 
expected_total_buckets,
+                             std::unique_ptr<FileBatchReader>* reader) const;
+
     ManifestFile(const std::shared_ptr<FileSystem>& file_system,
                  const std::shared_ptr<ReaderBuilder>& reader_builder,
                  const std::shared_ptr<WriterBuilder>& writer_builder,
diff --git a/src/paimon/core/manifest/manifest_file_test.cpp 
b/src/paimon/core/manifest/manifest_file_test.cpp
index 0ce9cc72..df5bcbd8 100644
--- a/src/paimon/core/manifest/manifest_file_test.cpp
+++ b/src/paimon/core/manifest/manifest_file_test.cpp
@@ -25,13 +25,18 @@
 #include <utility>
 
 #include "arrow/api.h"
+#include "arrow/ipc/json_simple.h"
+#include "fmt/format.h"
 #include "gtest/gtest.h"
 #include "paimon/common/data/binary_row.h"
 #include "paimon/common/data/data_define.h"
+#include "paimon/common/utils/checked_cast.h"
 #include "paimon/core/io/data_file_meta.h"
+#include "paimon/core/io/meta_to_arrow_array_converter.h"
 #include "paimon/core/manifest/file_kind.h"
 #include "paimon/core/manifest/file_source.h"
 #include "paimon/core/manifest/manifest_entry.h"
+#include "paimon/core/manifest/manifest_entry_serializer.h"
 #include "paimon/core/manifest/manifest_file_meta.h"
 #include "paimon/core/stats/simple_stats.h"
 #include "paimon/core/utils/file_store_path_factory.h"
@@ -40,6 +45,8 @@
 #include "paimon/defs.h"
 #include "paimon/format/file_format.h"
 #include "paimon/format/file_format_factory.h"
+#include "paimon/format/format_writer.h"
+#include "paimon/format/writer_builder.h"
 #include "paimon/fs/local/local_file_system.h"
 #include "paimon/memory/memory_pool.h"
 #include "paimon/testing/utils/binary_row_generator.h"
@@ -112,32 +119,51 @@ class ManifestFileTest : public testing::Test {
         const std::string& file_format_str, const std::string& root_path,
         const std::string& file_name, const std::shared_ptr<MemoryPool>& pool,
         const std::optional<int32_t>& bucket = std::nullopt) const {
+        EXPECT_OK_AND_ASSIGN(
+            std::vector<ManifestEntry> entries,
+            TryReadManifestEntry(file_format_str, root_path, file_name, pool, 
bucket,
+                                 /*cache_enabled=*/false, 
/*inferred_bucket=*/false));
+        return entries;
+    }
+
+    Result<std::vector<ManifestEntry>> TryReadManifestEntry(
+        const std::string& file_format_str, const std::string& root_path,
+        const std::string& file_name, const std::shared_ptr<MemoryPool>& pool,
+        const std::optional<int32_t>& bucket, bool cache_enabled, bool 
inferred_bucket) const {
         std::shared_ptr<FileSystem> file_system = 
std::make_shared<LocalFileSystem>();
-        EXPECT_OK_AND_ASSIGN(std::shared_ptr<FileFormat> file_format,
-                             FileFormatFactory::Get(file_format_str, {}));
+        PAIMON_ASSIGN_OR_RAISE(std::shared_ptr<FileFormat> file_format,
+                               FileFormatFactory::Get(file_format_str, {}));
         auto unused_schema = 
arrow::schema(arrow::FieldVector({arrow::field("f0", arrow::utf8())}));
-        EXPECT_OK_AND_ASSIGN(std::shared_ptr<FileStorePathFactory> 
path_factory,
-                             FileStorePathFactory::Create(
-                                 root_path, unused_schema, 
/*partition_keys=*/{},
-                                 /*default_part_value=*/"", 
file_format->Identifier(),
-                                 /*data_file_prefix=*/"data-",
-                                 /*legacy_partition_name_enabled=*/true, 
/*external_paths=*/{},
-                                 /*global_index_external_path=*/std::nullopt,
-                                 /*index_file_in_data_file_dir=*/false, pool));
-        EXPECT_OK_AND_ASSIGN(CoreOptions options,
-                             CoreOptions::FromMap({{Options::FILE_FORMAT, 
"orc"}}));
-        EXPECT_OK_AND_ASSIGN(
+        PAIMON_ASSIGN_OR_RAISE(std::shared_ptr<FileStorePathFactory> 
path_factory,
+                               FileStorePathFactory::Create(
+                                   root_path, unused_schema, 
/*partition_keys=*/{},
+                                   /*default_part_value=*/"", 
file_format->Identifier(),
+                                   /*data_file_prefix=*/"data-",
+                                   /*legacy_partition_name_enabled=*/true, 
/*external_paths=*/{},
+                                   /*global_index_external_path=*/std::nullopt,
+                                   /*index_file_in_data_file_dir=*/false, 
pool));
+        PAIMON_ASSIGN_OR_RAISE(CoreOptions options,
+                               CoreOptions::FromMap({{Options::FILE_FORMAT, 
"orc"}}));
+        if (cache_enabled) {
+            options.WithCache(
+                std::make_shared<CountingRoutingCache>(CacheKind::MANIFEST, 64 
* 1024 * 1024));
+        }
+        PAIMON_ASSIGN_OR_RAISE(
             std::unique_ptr<ManifestFile> manifest_file,
             ManifestFile::Create(file_system, file_format, "zstd", 
path_factory,
                                  /*target_file_size=*/1024, pool, options, 
unused_schema));
         std::vector<ManifestEntry> manifest_entries;
-        if (bucket) {
-            EXPECT_OK(manifest_file->ReadBucketEntries(file_name, 
bucket.value(),
-                                                       
/*file_size=*/std::nullopt,
-                                                       &manifest_entries));
+        if (bucket && inferred_bucket) {
+            PAIMON_RETURN_NOT_OK(manifest_file->ReadBucketEntries(
+                file_name, bucket.value(), /*expected_total_buckets=*/2, 
/*file_size=*/std::nullopt,
+                &manifest_entries));
+        } else if (bucket) {
+            PAIMON_RETURN_NOT_OK(
+                manifest_file->ReadBucketEntries(file_name, bucket.value(), 
std::nullopt,
+                                                 /*file_size=*/std::nullopt, 
&manifest_entries));
         } else {
-            EXPECT_OK(manifest_file->Read(file_name, /*filter=*/nullptr,
-                                          /*file_size=*/std::nullopt, 
&manifest_entries));
+            PAIMON_RETURN_NOT_OK(manifest_file->Read(
+                file_name, /*filter=*/nullptr, /*file_size=*/std::nullopt, 
&manifest_entries));
         }
 
         return manifest_entries;
@@ -360,17 +386,17 @@ TEST_F(ManifestFileTest, 
TestReadBucketEntriesMaterializesOnlySelectedBucket) {
     ASSERT_EQ(2, all_entries.size());
 
     std::vector<ManifestEntry> bucket_one_entries;
-    ASSERT_OK(manifest_file->ReadBucketEntries(manifest_name, /*bucket=*/1,
+    ASSERT_OK(manifest_file->ReadBucketEntries(manifest_name, /*bucket=*/1, 
std::nullopt,
                                                /*file_size=*/std::nullopt, 
&bucket_one_entries));
     ASSERT_EQ(std::vector<ManifestEntry>({all_entries[0]}), 
bucket_one_entries);
 
     std::vector<ManifestEntry> bucket_zero_entries;
-    ASSERT_OK(manifest_file->ReadBucketEntries(manifest_name, /*bucket=*/0,
+    ASSERT_OK(manifest_file->ReadBucketEntries(manifest_name, /*bucket=*/0, 
std::nullopt,
                                                /*file_size=*/std::nullopt, 
&bucket_zero_entries));
     ASSERT_EQ(std::vector<ManifestEntry>({all_entries[1]}), 
bucket_zero_entries);
 
     std::vector<ManifestEntry> missing_bucket_entries;
-    ASSERT_OK(manifest_file->ReadBucketEntries(manifest_name, /*bucket=*/2,
+    ASSERT_OK(manifest_file->ReadBucketEntries(manifest_name, /*bucket=*/2, 
std::nullopt,
                                                /*file_size=*/std::nullopt,
                                                &missing_bucket_entries));
     ASSERT_TRUE(missing_bucket_entries.empty());
@@ -417,14 +443,87 @@ TEST_F(ManifestFileTest, TestReadPassesKnownSizeToOpen) {
     ASSERT_EQ(0, counting_file_system->open_count);
 
     std::vector<ManifestEntry> bucket_one_entries;
-    ASSERT_OK(manifest_file->ReadBucketEntries(manifest_name, /*bucket=*/1, 
kRecordedSize,
-                                               &bucket_one_entries));
+    ASSERT_OK(manifest_file->ReadBucketEntries(manifest_name, /*bucket=*/1, 
std::nullopt,
+                                               kRecordedSize, 
&bucket_one_entries));
     ASSERT_EQ(std::vector<ManifestEntry>({all_entries[0]}), 
bucket_one_entries);
     ASSERT_EQ(std::vector<int64_t>({kRecordedSize, kRecordedSize}),
               counting_file_system->opened_lengths);
     ASSERT_EQ(0, counting_file_system->open_count);
 }
 
+TEST_F(ManifestFileTest, TestInferredBucketProbeSkipsArrowMaterialization) {
+    auto pool = GetDefaultPool();
+    std::vector<ManifestEntry> source_entries =
+        ReadManifestEntry("orc", paimon::test::GetDataDir() + 
"/orc/append_09.db/append_09",
+                          "manifest-3a44a0da-1008-463c-914e-28d271375e24-0", 
pool);
+    ASSERT_EQ(2, source_entries.size());
+
+    for (int32_t cache_mode : {0, 1, 2}) {
+        SCOPED_TRACE(cache_mode);
+        auto test_dir = UniqueTestDirectory::Create();
+        ASSERT_TRUE(test_dir);
+        auto file_system = std::make_shared<CountingFileSystem>();
+        
ASSERT_OK(file_system->Mkdirs(FileStorePathFactory::ManifestPath(test_dir->Str())));
+        ASSERT_OK_AND_ASSIGN(std::shared_ptr<FileFormat> file_format,
+                             FileFormatFactory::Get("avro", {}));
+        auto unused_schema = 
arrow::schema(arrow::FieldVector({arrow::field("f0", arrow::utf8())}));
+        ASSERT_OK_AND_ASSIGN(std::shared_ptr<FileStorePathFactory> 
path_factory,
+                             FileStorePathFactory::Create(
+                                 test_dir->Str(), unused_schema, 
/*partition_keys=*/{},
+                                 /*default_part_value=*/"", 
file_format->Identifier(),
+                                 /*data_file_prefix=*/"data-",
+                                 /*legacy_partition_name_enabled=*/true, 
/*external_paths=*/{},
+                                 /*global_index_external_path=*/std::nullopt,
+                                 /*index_file_in_data_file_dir=*/false, pool));
+        ASSERT_OK_AND_ASSIGN(CoreOptions options,
+                             CoreOptions::FromMap({{Options::FILE_FORMAT, 
"orc"}}));
+        auto cache = std::make_shared<CountingRoutingCache>(
+            cache_mode == 1 ? CacheKind::MANIFEST : CacheKind::DEFAULT, 64 * 
1024 * 1024);
+        if (cache_mode != 0) {
+            options.WithCache(cache);
+        }
+        ASSERT_OK_AND_ASSIGN(
+            std::unique_ptr<ManifestFile> manifest_file,
+            ManifestFile::Create(file_system, file_format, "null", 
path_factory,
+                                 /*target_file_size=*/1024, pool, options, 
unused_schema));
+
+        constexpr size_t payload_size = 8 * 1024 * 1024;
+        auto large_file = 
std::make_shared<DataFileMeta>(*source_entries[0].File());
+        large_file->file_name.assign(payload_size, 'x');
+        // A schema change alone must not force unrelated buckets to be 
materialized.
+        large_file->schema_id = source_entries[1].File()->schema_id + 1;
+        ManifestEntry excluded(FileKind::Add(), source_entries[0].Partition(), 
1, 2, large_file);
+        ManifestEntry selected(FileKind::Add(), source_entries[1].Partition(), 
0, 2,
+                               source_entries[1].File());
+        using WrittenFile = std::pair<std::string, int64_t>;
+        ASSERT_OK_AND_ASSIGN(WrittenFile written,
+                             manifest_file->WriteWithoutRolling({excluded, 
selected}));
+        std::vector<ManifestEntry> warm;
+        ASSERT_OK(manifest_file->Read(written.first, nullptr, 
/*file_size=*/std::nullopt, &warm));
+        // Cached bytes, if available, belong to a separate pool from reader 
allocations.
+        for (bool inferred : {false, true}) {
+            std::shared_ptr<MemoryPool> read_pool = GetMemoryPool();
+            ASSERT_OK_AND_ASSIGN(
+                auto reader, ManifestFile::Create(file_system, file_format, 
"null", path_factory,
+                                                  1024, read_pool, options, 
unused_schema));
+            std::vector<ManifestEntry> entries;
+            if (inferred) {
+                ASSERT_OK(reader->ReadBucketEntries(written.first, 0, 
/*expected_total_buckets=*/2,
+                                                    
/*file_size=*/std::nullopt, &entries));
+                ASSERT_EQ(std::vector<ManifestEntry>({selected}), entries);
+                ASSERT_LT(read_pool->MaxMemoryUsage(), payload_size / 2);
+            } else {
+                ASSERT_OK(
+                    reader->Read(written.first, nullptr, 
/*file_size=*/std::nullopt, &entries));
+                ASSERT_EQ(warm, entries);
+                ASSERT_GE(read_pool->MaxMemoryUsage(), payload_size);
+            }
+        }
+        ASSERT_EQ(cache_mode == 1 ? 1 : 3, file_system->open_count);
+        ASSERT_EQ(cache_mode == 1 ? 1 : 0, cache->SupplierCallCount());
+    }
+}
+
 TEST_F(ManifestFileTest, TestReadBucketEntriesSkipsDeserializingOtherBuckets) {
     auto pool = GetDefaultPool();
     std::vector<ManifestEntry> source_entries =
@@ -432,47 +531,194 @@ TEST_F(ManifestFileTest, 
TestReadBucketEntriesSkipsDeserializingOtherBuckets) {
                           "manifest-3a44a0da-1008-463c-914e-28d271375e24-0", 
pool);
     ASSERT_EQ(2, source_entries.size());
 
-    auto test_dir = UniqueTestDirectory::Create();
-    ASSERT_TRUE(test_dir);
-    std::shared_ptr<FileSystem> file_system = test_dir->GetFileSystem();
-    
ASSERT_OK(file_system->Mkdirs(FileStorePathFactory::ManifestPath(test_dir->Str())));
-    ASSERT_OK_AND_ASSIGN(std::shared_ptr<FileFormat> file_format,
-                         FileFormatFactory::Get("avro", {}));
-    auto unused_schema = arrow::schema(arrow::FieldVector({arrow::field("f0", 
arrow::utf8())}));
-    ASSERT_OK_AND_ASSIGN(
-        std::shared_ptr<FileStorePathFactory> path_factory,
-        FileStorePathFactory::Create(test_dir->Str(), unused_schema, 
/*partition_keys=*/{},
-                                     /*default_part_value=*/"", 
file_format->Identifier(),
-                                     /*data_file_prefix=*/"data-",
-                                     /*legacy_partition_name_enabled=*/true, 
/*external_paths=*/{},
-                                     
/*global_index_external_path=*/std::nullopt,
-                                     /*index_file_in_data_file_dir=*/false, 
pool));
-    ASSERT_OK_AND_ASSIGN(CoreOptions options,
-                         CoreOptions::FromMap({{Options::FILE_FORMAT, 
"orc"}}));
-    ASSERT_OK_AND_ASSIGN(
-        std::unique_ptr<ManifestFile> manifest_file,
-        ManifestFile::Create(file_system, file_format, "zstd", path_factory,
-                             /*target_file_size=*/1024, pool, options, 
unused_schema));
+    // Selective decoding also works without a cache or when the cache rejects 
manifest keys.
+    for (int32_t cache_mode : {0, 1, 2}) {
+        SCOPED_TRACE(cache_mode);
+        auto test_dir = UniqueTestDirectory::Create();
+        ASSERT_TRUE(test_dir);
+        auto file_system = std::make_shared<CountingFileSystem>();
+        
ASSERT_OK(file_system->Mkdirs(FileStorePathFactory::ManifestPath(test_dir->Str())));
+        ASSERT_OK_AND_ASSIGN(std::shared_ptr<FileFormat> file_format,
+                             FileFormatFactory::Get("avro", {}));
+        auto unused_schema = 
arrow::schema(arrow::FieldVector({arrow::field("f0", arrow::utf8())}));
+        ASSERT_OK_AND_ASSIGN(std::shared_ptr<FileStorePathFactory> 
path_factory,
+                             FileStorePathFactory::Create(
+                                 test_dir->Str(), unused_schema, 
/*partition_keys=*/{},
+                                 /*default_part_value=*/"", 
file_format->Identifier(),
+                                 /*data_file_prefix=*/"data-",
+                                 /*legacy_partition_name_enabled=*/true, 
/*external_paths=*/{},
+                                 /*global_index_external_path=*/std::nullopt,
+                                 /*index_file_in_data_file_dir=*/false, pool));
+        ASSERT_OK_AND_ASSIGN(CoreOptions options,
+                             CoreOptions::FromMap({{Options::FILE_FORMAT, 
"orc"}}));
+        if (cache_mode != 0) {
+            options.WithCache(std::make_shared<CountingRoutingCache>(
+                cache_mode == 1 ? CacheKind::MANIFEST : CacheKind::DEFAULT, 64 
* 1024 * 1024));
+        }
+        ASSERT_OK_AND_ASSIGN(
+            std::unique_ptr<ManifestFile> manifest_file,
+            ManifestFile::Create(file_system, file_format, "null", 
path_factory,
+                                 /*target_file_size=*/1024, pool, options, 
unused_schema));
 
-    ManifestEntry invalid_other_bucket(FileKind(static_cast<int8_t>(2)),
-                                       source_entries[0].Partition(), 
/*bucket=*/1,
-                                       /*total_buckets=*/2, 
source_entries[0].File());
-    ManifestEntry valid_target_bucket(FileKind::Add(), 
source_entries[1].Partition(), /*bucket=*/0,
-                                      /*total_buckets=*/2, 
source_entries[1].File());
-    using WrittenFile = std::pair<std::string, int64_t>;
-    ASSERT_OK_AND_ASSIGN(
-        WrittenFile written_file,
-        manifest_file->WriteWithoutRolling({invalid_other_bucket, 
valid_target_bucket}));
+        ManifestEntry invalid_other_bucket(FileKind(static_cast<int8_t>(2)),
+                                           source_entries[0].Partition(), 
/*bucket=*/1,
+                                           /*total_buckets=*/2, 
source_entries[0].File());
+        ManifestEntry valid_target_bucket(FileKind::Add(), 
source_entries[1].Partition(),
+                                          /*bucket=*/0,
+                                          /*total_buckets=*/2, 
source_entries[1].File());
+        using WrittenFile = std::pair<std::string, int64_t>;
+        ASSERT_OK_AND_ASSIGN(
+            WrittenFile written_file,
+            manifest_file->WriteWithoutRolling({invalid_other_bucket, 
valid_target_bucket}));
+
+        // Exercise both a cold cache and reuse of the retained manifest bytes.
+        for (int32_t read = 0; read < 2; ++read) {
+            std::vector<ManifestEntry> bucket_entries;
+            ASSERT_OK(manifest_file->ReadBucketEntries(written_file.first, 
/*bucket=*/0,
+                                                       std::nullopt, 
/*file_size=*/std::nullopt,
+                                                       &bucket_entries));
+            ASSERT_EQ(std::vector<ManifestEntry>({valid_target_bucket}), 
bucket_entries);
+            ASSERT_EQ(cache_mode == 1 ? 1 : read + 1, file_system->open_count);
+        }
+        std::vector<ManifestEntry> missing_bucket_entries;
+        ASSERT_OK(manifest_file->ReadBucketEntries(written_file.first, 
/*bucket=*/2, std::nullopt,
+                                                   /*file_size=*/std::nullopt,
+                                                   &missing_bucket_entries));
+        ASSERT_TRUE(missing_bucket_entries.empty());
+        std::vector<ManifestEntry> all_entries;
+        ASSERT_NOK_WITH_MSG(manifest_file->Read(written_file.first, 
/*filter=*/nullptr,
+                                                /*file_size=*/std::nullopt, 
&all_entries),
+                            "Unsupported byte value 2 for file kind.");
+    }
+}
 
-    std::vector<ManifestEntry> all_entries;
-    ASSERT_NOK_WITH_MSG(manifest_file->Read(written_file.first, 
/*filter=*/nullptr,
-                                            /*file_size=*/std::nullopt, 
&all_entries),
-                        "Unsupported byte value 2 for file kind.");
-
-    std::vector<ManifestEntry> bucket_entries;
-    ASSERT_OK(manifest_file->ReadBucketEntries(written_file.first, 
/*bucket=*/0,
-                                               /*file_size=*/std::nullopt, 
&bucket_entries));
-    ASSERT_EQ(std::vector<ManifestEntry>({valid_target_bucket}), 
bucket_entries);
+TEST_F(ManifestFileTest, TestReadBucketEntriesWithReorderedFields) {
+    auto pool = GetDefaultPool();
+    auto source =
+        ReadManifestEntry("orc", paimon::test::GetDataDir() + 
"/orc/append_09.db/append_09",
+                          "manifest-3a44a0da-1008-463c-914e-28d271375e24-0", 
pool);
+    ASSERT_EQ(source.size(), 2);
+    std::vector<ManifestEntry> entries;
+    std::vector<BinaryRow> rows;
+    ManifestEntrySerializer serializer(pool);
+    for (int32_t bucket = 0; bucket < 2; ++bucket) {
+        entries.emplace_back(FileKind::Add(), source[bucket].Partition(), 
bucket, 2,
+                             source[bucket].File());
+        ASSERT_OK_AND_ASSIGN(BinaryRow row, serializer.ToRow(entries.back()));
+        rows.push_back(std::move(row));
+    }
+    ASSERT_OK_AND_ASSIGN(std::unique_ptr<MetaToArrowArrayConverter> converter,
+                         
MetaToArrowArrayConverter::Create(serializer.GetDataType(), pool));
+    ASSERT_OK_AND_ASSIGN(std::shared_ptr<arrow::Array> array, 
converter->NextBatch(rows));
+    auto struct_array = checked_pointer_cast<arrow::StructArray>(array);
+    ASSERT_OK_AND_ASSIGN(std::shared_ptr<FileFormat> format, 
FileFormatFactory::Get("avro", {}));
+    // Move each selector field away from its standard position. Ordinary 
schema alignment
+    // must still restore the fields before version validation and bucket 
filtering.
+    for (int32_t field_index : {0, 3, 4}) {
+        SCOPED_TRACE(field_index);
+        auto fields = serializer.GetDataType()->fields();
+        auto columns = struct_array->fields();
+        std::swap(fields[1], fields[field_index]);
+        std::swap(columns[1], columns[field_index]);
+        auto reordered_result = arrow::StructArray::Make(columns, fields);
+        ASSERT_TRUE(reordered_result.ok()) << 
reordered_result.status().ToString();
+        std::shared_ptr<arrow::StructArray> reordered = 
reordered_result.ValueOrDie();
+        auto dir = UniqueTestDirectory::Create();
+        ASSERT_TRUE(dir);
+        auto fs = dir->GetFileSystem();
+        const std::string manifest_dir = 
FileStorePathFactory::ManifestPath(dir->Str());
+        ASSERT_OK(fs->Mkdirs(manifest_dir));
+        const std::string file_name = "manifest-reordered";
+        ArrowSchema c_schema;
+        ASSERT_TRUE(arrow::ExportSchema(*arrow::schema(fields), 
&c_schema).ok());
+        ASSERT_OK_AND_ASSIGN(std::shared_ptr<WriterBuilder> builder,
+                             format->CreateWriterBuilder(&c_schema, 2));
+        ASSERT_OK_AND_ASSIGN(std::shared_ptr<OutputStream> output,
+                             fs->Create(manifest_dir + "/" + file_name, 
false));
+        ASSERT_OK_AND_ASSIGN(std::unique_ptr<FormatWriter> writer, 
builder->Build(output, "null"));
+        ArrowArray c_array;
+        ASSERT_TRUE(arrow::ExportArray(*reordered, &c_array).ok());
+        ASSERT_OK(writer->AddBatch(&c_array));
+        ASSERT_OK(writer->Flush());
+        ASSERT_OK(writer->Finish());
+        ASSERT_OK(output->Close());
+        for (bool cache_enabled : {false, true}) {
+            for (bool inferred_bucket : {false, true}) {
+                for (int32_t bucket : {0, 1, 2}) {
+                    ASSERT_OK_AND_ASSIGN(
+                        std::vector<ManifestEntry> actual,
+                        TryReadManifestEntry("avro", dir->Str(), file_name, 
pool, bucket,
+                                             cache_enabled, inferred_bucket));
+                    const std::vector<ManifestEntry> expected =
+                        bucket < 2 ? 
std::vector<ManifestEntry>{entries[bucket]}
+                                   : std::vector<ManifestEntry>{};
+                    ASSERT_EQ(actual, expected);
+                }
+            }
+        }
+    }
+}
+
+TEST_F(ManifestFileTest, TestReadBucketEntriesValidatesAllVersions) {
+    auto pool = GetDefaultPool();
+    ManifestEntrySerializer serializer(pool);
+    arrow::FieldVector fields;
+    for (const auto& field : serializer.GetDataType()->fields()) {
+        fields.push_back(field->WithNullable(true));
+    }
+    auto type = arrow::struct_(fields);
+    const std::vector<std::pair<std::string, std::string>> cases = {
+        {"999", "Unsupported version: 999"}, {"1", "not compatible"}};
+    for (const auto& item : cases) {
+        SCOPED_TRACE(item.first);
+        for (int32_t total_buckets : {2, 3}) {
+            SCOPED_TRACE(total_buckets);
+            auto dir = UniqueTestDirectory::Create();
+            ASSERT_TRUE(dir);
+            auto fs = dir->GetFileSystem();
+            const std::string manifest_dir = 
FileStorePathFactory::ManifestPath(dir->Str());
+            ASSERT_OK(fs->Mkdirs(manifest_dir));
+            const std::string file_name = "manifest-invalid-version";
+            ASSERT_OK_AND_ASSIGN(std::shared_ptr<FileFormat> format,
+                                 FileFormatFactory::Get("avro", {}));
+            ArrowSchema c_schema;
+            ASSERT_TRUE(arrow::ExportSchema(*arrow::schema(type->fields()), 
&c_schema).ok());
+            ASSERT_OK_AND_ASSIGN(std::shared_ptr<WriterBuilder> builder,
+                                 format->CreateWriterBuilder(&c_schema, 2));
+            ASSERT_OK_AND_ASSIGN(std::shared_ptr<OutputStream> output,
+                                 fs->Create(manifest_dir + "/" + file_name, 
false));
+            ASSERT_OK_AND_ASSIGN(std::unique_ptr<FormatWriter> writer,
+                                 builder->Build(output, "null"));
+            // A uniform unsupported version must fail before decoding the 
null metadata.
+            auto array =
+                arrow::ipc::internal::json::ArrayFromJSON(
+                    type, fmt::format("[[{},0,null,1,{},null]]", item.first, 
total_buckets))
+                    .ValueOrDie();
+            ArrowArray c_array;
+            ASSERT_TRUE(arrow::ExportArray(*array, &c_array).ok());
+            ASSERT_OK(writer->AddBatch(&c_array));
+            ASSERT_OK(writer->Flush());
+            ASSERT_OK(writer->Finish());
+            ASSERT_OK(output->Close());
+
+            for (bool cache_enabled : {false, true}) {
+                SCOPED_TRACE(cache_enabled);
+                for (bool inferred_bucket : {false, true}) {
+                    SCOPED_TRACE(inferred_bucket);
+                    // Matching entries always undergo version validation.
+                    ASSERT_NOK_WITH_MSG(
+                        TryReadManifestEntry("avro", dir->Str(), file_name, 
pool, /*bucket=*/1,
+                                             cache_enabled, inferred_bucket),
+                        item.second);
+                    // An unsupported version must fail even when its bucket 
does not match.
+                    ASSERT_NOK_WITH_MSG(
+                        TryReadManifestEntry("avro", dir->Str(), file_name, 
pool, /*bucket=*/0,
+                                             cache_enabled, inferred_bucket),
+                        item.second);
+                }
+            }
+        }
+    }
 }
 
 TEST_F(ManifestFileTest, TestLegacyManifestFormatIsReadOnly) {
@@ -600,6 +846,16 @@ TEST_F(ManifestFileTest, 
TestManifestFileCompatibleWithJavaPaimon09) {
     ASSERT_EQ(expected_manifest_entries,
               ReadManifestEntry("avro", paimon::test::GetDataDir() + "/avro", 
"avro_manifest_09",
                                 pool, /*bucket=*/0));
+    ASSERT_OK_AND_ASSIGN(std::vector<ManifestEntry> cached_entries,
+                         TryReadManifestEntry("avro", 
paimon::test::GetDataDir() + "/avro",
+                                              "avro_manifest_09", pool, 
/*bucket=*/0,
+                                              /*cache_enabled=*/true, false));
+    ASSERT_EQ(expected_manifest_entries, cached_entries);
+    ASSERT_OK_AND_ASSIGN(std::vector<ManifestEntry> inferred_entries,
+                         TryReadManifestEntry("avro", 
paimon::test::GetDataDir() + "/avro",
+                                              "avro_manifest_09", pool, 
/*bucket=*/0,
+                                              /*cache_enabled=*/true, 
/*inferred_bucket=*/true));
+    ASSERT_EQ(expected_manifest_entries, inferred_entries);
 }
 
 TEST_F(ManifestFileTest, TestManifestFileCompatibleWithJavaPaimon11) {
@@ -639,6 +895,16 @@ TEST_F(ManifestFileTest, 
TestManifestFileCompatibleWithJavaPaimon11) {
     ASSERT_EQ(expected_manifest_entries,
               ReadManifestEntry("avro", paimon::test::GetDataDir() + "/avro", 
"avro_manifest_11",
                                 pool, /*bucket=*/0));
+    ASSERT_OK_AND_ASSIGN(std::vector<ManifestEntry> cached_entries,
+                         TryReadManifestEntry("avro", 
paimon::test::GetDataDir() + "/avro",
+                                              "avro_manifest_11", pool, 
/*bucket=*/0,
+                                              /*cache_enabled=*/true, false));
+    ASSERT_EQ(expected_manifest_entries, cached_entries);
+    ASSERT_OK_AND_ASSIGN(std::vector<ManifestEntry> inferred_entries,
+                         TryReadManifestEntry("avro", 
paimon::test::GetDataDir() + "/avro",
+                                              "avro_manifest_11", pool, 
/*bucket=*/0,
+                                              /*cache_enabled=*/true, 
/*inferred_bucket=*/true));
+    ASSERT_EQ(expected_manifest_entries, inferred_entries);
 }
 
 }  // namespace paimon::test
diff --git a/src/paimon/core/operation/append_only_file_store_scan_test.cpp 
b/src/paimon/core/operation/append_only_file_store_scan_test.cpp
index b45a5684..908f9bbb 100644
--- a/src/paimon/core/operation/append_only_file_store_scan_test.cpp
+++ b/src/paimon/core/operation/append_only_file_store_scan_test.cpp
@@ -19,6 +19,7 @@
 #include "paimon/core/operation/append_only_file_store_scan.h"
 
 #include <algorithm>
+#include <atomic>
 #include <cstdint>
 #include <map>
 #include <optional>
@@ -33,15 +34,21 @@
 #include "paimon/common/io/cache/lru_cache.h"
 #include "paimon/common/utils/math.h"
 #include "paimon/core/bucket/default_bucket_function.h"
+#include "paimon/core/io/meta_to_arrow_array_converter.h"
 #include "paimon/core/manifest/manifest_entry.h"
+#include "paimon/core/manifest/manifest_entry_serializer.h"
+#include "paimon/core/manifest/manifest_file.h"
+#include "paimon/core/manifest/manifest_file_meta.h"
 #include "paimon/core/manifest/partition_entry.h"
 #include "paimon/core/schema/schema_manager.h"
 #include "paimon/core/schema/table_schema.h"
 #include "paimon/core/stats/simple_stats_evolution.h"
 #include "paimon/core/table/source/abstract_table_scan.h"
 #include "paimon/core/table/source/snapshot/snapshot_reader.h"
+#include "paimon/core/utils/file_store_path_factory.h"
 #include "paimon/defs.h"
 #include "paimon/executor.h"
+#include "paimon/format/file_format_factory.h"
 #include "paimon/fs/local/local_file_system.h"
 #include "paimon/memory/memory_pool.h"
 #include "paimon/metrics.h"
@@ -51,7 +58,10 @@
 #include "paimon/status.h"
 #include "paimon/table/source/scan_metrics.h"
 #include "paimon/table/source/table_scan.h"
+#include "paimon/testing/mock/mock_file_batch_reader.h"
+#include "paimon/testing/mock/mock_format_reader_builder.h"
 #include "paimon/testing/utils/binary_row_generator.h"
+#include "paimon/testing/utils/test_helper.h"
 #include "paimon/testing/utils/testharness.h"
 #include "paimon/testing/utils/timezone_guard.h"
 namespace paimon::test {
@@ -141,12 +151,315 @@ class AppendBucketPruningTest : public testing::Test {
                                                    {Options::BUCKET_KEY, 
"rowkey"}};
 };
 
+namespace {
+
+class CountingSchemaFileSystem : public LocalFileSystem {
+ public:
+    Status ReadFile(const std::string& path, std::string* content) override {
+        ++read_count;
+        return LocalFileSystem::ReadFile(path, content);
+    }
+
+    int32_t read_count = 0;
+};
+
+class CountingManifestEntrySerializer : public ManifestEntrySerializer {
+ public:
+    explicit CountingManifestEntrySerializer(const 
std::shared_ptr<MemoryPool>& pool)
+        : ManifestEntrySerializer(pool) {}
+
+    Result<ManifestEntry> ConvertFrom(int32_t version, const InternalRow& row) 
const override {
+        ++decoded_entries;
+        return ManifestEntrySerializer::ConvertFrom(version, row);
+    }
+
+    mutable std::atomic<int64_t> decoded_entries{0};
+};
+
+class CountingManifestReaderBuilder : public MockFormatReaderBuilder {
+ public:
+    explicit CountingManifestReaderBuilder(const 
std::shared_ptr<arrow::Array>& data)
+        : MockFormatReaderBuilder(data, data->type(), 2), data_(data) {}
+
+    Result<std::unique_ptr<FileBatchReader>> Build(
+        const std::shared_ptr<InputStream>& input) const override {
+        return std::make_unique<Reader>(data_, &rows_read);
+    }
+
+    mutable std::atomic<int64_t> rows_read{0};
+
+ private:
+    class Reader : public MockFileBatchReader {
+     public:
+        Reader(const std::shared_ptr<arrow::Array>& data, 
std::atomic<int64_t>* rows_read)
+            : MockFileBatchReader(data, data->type(), 2), 
rows_read_(rows_read) {
+            EnableRandomizeBatchSize(false);
+        }
+
+        bool SupportPreciseBitmapSelection() const override {
+            return true;
+        }
+
+        Result<ReadBatchWithBitmap> NextBatchWithBitmap() override {
+            PAIMON_ASSIGN_OR_RAISE(ReadBatchWithBitmap batch,
+                                   MockFileBatchReader::NextBatchWithBitmap());
+            if (!BatchReader::IsEofBatch(batch)) {
+                *rows_read_ += batch.first.first->length;
+            }
+            return batch;
+        }
+
+     private:
+        std::atomic<int64_t>* rows_read_;
+    };
+
+    std::shared_ptr<arrow::Array> data_;
+};
+
+}  // namespace
+
+TEST_F(AppendBucketPruningTest, ReadsSingleBucketManifestOnce) {
+    auto dir = UniqueTestDirectory::Create();
+    ASSERT_TRUE(dir);
+    auto fs = dir->GetFileSystem();
+    // The converter owns the Arrow memory pool used by the mock reader's 
shared data.
+    ManifestEntrySerializer serializer(pool_);
+    ASSERT_OK_AND_ASSIGN(std::unique_ptr<MetaToArrowArrayConverter> converter,
+                         
MetaToArrowArrayConverter::Create(serializer.GetDataType(), pool_));
+    ASSERT_OK_AND_ASSIGN(std::unique_ptr<AppendOnlyFileStoreScan> initial, 
CreateScan(KeyEquals()));
+    const int32_t bucket = initial->bucket_selector_->Bucket(kNumBuckets);
+    ASSERT_OK_AND_ASSIGN(std::shared_ptr<FileFormat> format, 
FileFormatFactory::Get("avro", {}));
+    ASSERT_OK_AND_ASSIGN(
+        std::shared_ptr<FileStorePathFactory> paths,
+        FileStorePathFactory::Create(dir->Str(), initial->schema_, {}, "", 
"avro", "data-", true,
+                                     {}, std::nullopt, false, pool_));
+    ASSERT_OK(fs->Mkdirs(FileStorePathFactory::ManifestPath(dir->Str())));
+    ASSERT_OK_AND_ASSIGN(CoreOptions options, CoreOptions::FromMap({}));
+    options.WithCache(std::make_shared<LruCache>(16 * 1024 * 1024));
+    ASSERT_OK_AND_ASSIGN(std::shared_ptr<ManifestFile> manifest,
+                         ManifestFile::Create(fs, format, "null", paths, 1024 
* 1024, pool_,
+                                              options, arrow::schema({})));
+    SimpleStats stats = BinaryRowGenerator::GenerateStats(
+        {std::string("a"), 0}, {std::string("z"), 100}, {0, 0}, pool_.get());
+    ASSERT_OK_AND_ASSIGN(
+        std::shared_ptr<DataFileMeta> file,
+        DataFileMeta::ForAppend("data.avro", 100, 10, stats, 0, 9, schema_id_, 
std::nullopt,
+                                std::nullopt, std::nullopt, std::nullopt, 
std::nullopt));
+    std::vector<ManifestEntry> entries = {
+        ManifestEntry(FileKind::Add(), BinaryRow::EmptyRow(), bucket, 
kNumBuckets, file)};
+    ASSERT_OK_AND_ASSIGN(std::vector<ManifestFileMeta> metas, 
manifest->Write(entries));
+    ASSERT_EQ(metas.size(), 1);
+    ASSERT_OK_AND_ASSIGN(BinaryRow row, serializer.ToRow(entries[0]));
+    ASSERT_OK_AND_ASSIGN(std::shared_ptr<arrow::Array> data, 
converter->NextBatch({row}));
+    auto reader_builder = 
std::make_shared<CountingManifestReaderBuilder>(data);
+    manifest->reader_builder_ = reader_builder;
+    options_[Options::SCAN_MANIFEST_ENTRY_LAZY_DECODE_ENABLED] = "true";
+    for (bool inferred : {false, true}) {
+        SCOPED_TRACE(inferred);
+        ASSERT_OK_AND_ASSIGN(
+            std::unique_ptr<AppendOnlyFileStoreScan> scan,
+            CreateScan(KeyEquals(), inferred ? std::nullopt : 
std::optional<int32_t>(bucket)));
+        scan->manifest_file_ = manifest;
+        for (bool known_bounds : {false, true}) {
+            SCOPED_TRACE(known_bounds);
+            auto meta = metas[0];
+            meta.min_bucket_ = known_bounds ? std::optional<int32_t>(bucket) : 
std::nullopt;
+            meta.max_bucket_ = meta.min_bucket_;
+            reader_builder->rows_read = 0;
+            std::vector<ManifestEntry> actual;
+            ASSERT_OK(scan->ReadAndMergeBucketFileEntries({meta}, bucket, 
&actual));
+            ASSERT_EQ(actual, entries);
+            ASSERT_EQ(reader_builder->rows_read.load(), known_bounds ? 1 : 2);
+        }
+    }
+}
+
+TEST_F(AppendBucketPruningTest, SelectivelyDecodesInferredBucketCandidates) {
+    auto dir = UniqueTestDirectory::Create();
+    ASSERT_TRUE(dir);
+    auto fs = dir->GetFileSystem();
+    schema_manager_ = std::make_shared<SchemaManager>(fs, dir->Str());
+    schema_id_ = 0;
+    ASSERT_OK_AND_ASSIGN(std::unique_ptr<AppendOnlyFileStoreScan> initial_scan,
+                         CreateScan(nullptr));
+    ASSERT_OK(schema_manager_->CreateTable(initial_scan->schema_, {}, {}, 
options_));
+    schema_id_ = 1;
+    ASSERT_OK_AND_ASSIGN(std::unique_ptr<AppendOnlyFileStoreScan> 
inferred_scan,
+                         CreateScan(KeyEquals()));
+    ASSERT_FALSE(inferred_scan->bucket_filter_);
+    ASSERT_TRUE(inferred_scan->bucket_selector_);
+    const int32_t bucket = 
inferred_scan->bucket_selector_->Bucket(kNumBuckets);
+    ASSERT_OK_AND_ASSIGN(std::shared_ptr<FileFormat> format, 
FileFormatFactory::Get("avro", {}));
+    ASSERT_OK_AND_ASSIGN(std::shared_ptr<FileStorePathFactory> paths,
+                         FileStorePathFactory::Create(
+                             dir->Str(), initial_scan->schema_, 
/*partition_keys=*/{},
+                             /*default_part_value=*/"", "avro", 
/*data_file_prefix=*/"data-",
+                             /*legacy_partition_name_enabled=*/true, 
/*external_paths=*/{},
+                             /*global_index_external_path=*/std::nullopt,
+                             /*index_file_in_data_file_dir=*/false, pool_));
+    ASSERT_OK(fs->Mkdirs(FileStorePathFactory::ManifestPath(dir->Str())));
+    SimpleStats stats = BinaryRowGenerator::GenerateStats(
+        {std::string("a"), 0}, {std::string("z"), 100}, {0, 0}, pool_.get());
+    std::vector<ManifestEntry> entries;
+    for (const auto& layout : std::vector<std::pair<int32_t, int64_t>>{
+             {kNumBuckets, schema_id_}, {8, schema_id_}, {kNumBuckets, 0}, 
{-1, schema_id_}}) {
+        for (int32_t id = 0; id < std::max(1, layout.first); ++id) {
+            ASSERT_OK_AND_ASSIGN(
+                std::shared_ptr<DataFileMeta> file,
+                DataFileMeta::ForAppend(fmt::format("data-{}.avro", 
entries.size()), 100, 10, stats,
+                                        0, 9, layout.second, std::nullopt, 
std::nullopt,
+                                        std::nullopt, std::nullopt, 
std::nullopt));
+            entries.emplace_back(FileKind::Add(), BinaryRow::EmptyRow(), id, 
layout.first, file);
+        }
+    }
+    ASSERT_OK_AND_ASSIGN(
+        std::shared_ptr<DataFileMeta> deleted_file,
+        DataFileMeta::ForAppend("deleted.avro", 100, 10, stats, 0, 9, 
schema_id_, std::nullopt,
+                                std::nullopt, std::nullopt, std::nullopt, 
std::nullopt));
+    entries.emplace_back(FileKind::Add(), BinaryRow::EmptyRow(), bucket, 
kNumBuckets, deleted_file);
+    ManifestEntry deletion(FileKind::Delete(), BinaryRow::EmptyRow(), bucket, 
kNumBuckets,
+                           deleted_file);
+    for (bool cache_enabled : {false, true}) {
+        SCOPED_TRACE(cache_enabled);
+        ASSERT_OK_AND_ASSIGN(CoreOptions manifest_options,
+                             CoreOptions::FromMap({{Options::READ_BATCH_SIZE, 
"2"}}));
+        if (cache_enabled) {
+            manifest_options.WithCache(std::make_shared<LruCache>(16 * 1024 * 
1024));
+        }
+        ASSERT_OK_AND_ASSIGN(std::shared_ptr<ManifestFile> manifest,
+                             ManifestFile::Create(fs, format, "null", paths, 
1024 * 1024, pool_,
+                                                  manifest_options, 
arrow::schema({})));
+        auto serializer = 
std::make_unique<CountingManifestEntrySerializer>(pool_);
+        auto* counter = serializer.get();
+        manifest->serializer_ = std::move(serializer);
+        using WrittenFile = std::pair<std::string, int64_t>;
+        ASSERT_OK_AND_ASSIGN(WrittenFile additions, 
manifest->WriteWithoutRolling(entries));
+        ASSERT_OK_AND_ASSIGN(WrittenFile deletions, 
manifest->WriteWithoutRolling({deletion}));
+        std::vector<ManifestFileMeta> metas;
+        for (const auto& file : {additions, deletions}) {
+            metas.emplace_back(file.first, file.second, 0, 0, 
SimpleStats::EmptyStats(), schema_id_,
+                               std::nullopt, std::nullopt, std::nullopt, 
std::nullopt, std::nullopt,
+                               std::nullopt);
+        }
+        std::vector<std::string> baseline_files;
+        for (bool lazy_decode : {false, true}) {
+            SCOPED_TRACE(lazy_decode);
+            options_[Options::SCAN_MANIFEST_ENTRY_LAZY_DECODE_ENABLED] =
+                lazy_decode ? "true" : "false";
+            ASSERT_OK_AND_ASSIGN(std::unique_ptr<AppendOnlyFileStoreScan> scan,
+                                 CreateScan(KeyEquals()));
+            scan->manifest_file_ = manifest;
+            counter->decoded_entries = 0;
+            std::vector<ManifestEntry> candidates;
+            ASSERT_OK(scan->ReadAndMergeBucketFileEntries(metas, bucket, 
&candidates));
+            // Both schema versions have the same bucket layout: skip their 
unrelated
+            // buckets, but retain different bucket counts and both sides of 
the deletion.
+            ASSERT_EQ(counter->decoded_entries.load(),
+                      entries.size() + 1 - (lazy_decode ? 2 * (kNumBuckets - 
1) : 0));
+            std::vector<std::string> files;
+            for (const auto& entry : candidates) {
+                ASSERT_NE(entry.FileName(), "deleted.avro");
+                ASSERT_OK_AND_ASSIGN(bool keep, 
scan->FilterManifestEntry(entry));
+                if (keep) {
+                    files.push_back(entry.FileName());
+                }
+            }
+            std::sort(files.begin(), files.end());
+            ASSERT_EQ(files.size(), 4);
+            if (!lazy_decode) {
+                baseline_files = files;
+            } else {
+                ASSERT_EQ(files, baseline_files);
+            }
+        }
+    }
+}
+
 TEST_F(AppendBucketPruningTest, PrunesStringKeyLookup) {
     BinaryRow key = BinaryRowGenerator::GenerateRow({std::string("key")}, 
pool_.get());
     int32_t expected_bucket = DefaultBucketFunction().Bucket(key, kNumBuckets);
     CheckBuckets(KeyEquals(), expected_bucket);
 }
 
+TEST_F(AppendBucketPruningTest, 
SelectivelyDecodesWithoutReadingHistoricalSchemas) {
+    auto dir = UniqueTestDirectory::Create();
+    ASSERT_TRUE(dir);
+    auto fs = std::make_shared<CountingSchemaFileSystem>();
+    schema_manager_ = std::make_shared<SchemaManager>(fs, dir->Str());
+    schema_id_ = 0;
+    rowkey_type_ = arrow::int32();
+    ASSERT_OK_AND_ASSIGN(std::unique_ptr<AppendOnlyFileStoreScan> initial, 
CreateScan(nullptr));
+    ASSERT_OK(schema_manager_->CreateTable(initial->schema_, {}, {}, 
options_));
+    schema_id_ = 1000;
+    auto predicate = PredicateBuilder::Equal(0, "rowkey", FieldType::INT, 
Literal(int32_t{-1}));
+    ASSERT_OK_AND_ASSIGN(std::unique_ptr<AppendOnlyFileStoreScan> current, 
CreateScan(predicate));
+    // Only schema 0 has a schema file; unused historical versions must not be 
read.
+    const int32_t bucket = current->bucket_selector_->Bucket(kNumBuckets);
+    SimpleStats old_stats = BinaryRowGenerator::GenerateStats({int32_t{-100}, 
0}, {int32_t{0}, 100},
+                                                              {0, 0}, 
pool_.get());
+    std::vector<ManifestEntry> entries;
+    for (int32_t id = 0; id <= kNumBuckets; ++id) {
+        const bool historical = id < kNumBuckets;
+        ASSERT_OK_AND_ASSIGN(
+            std::shared_ptr<DataFileMeta> file,
+            DataFileMeta::ForAppend(fmt::format("data-{}.avro", id), 100, 10, 
old_stats, 0, 9,
+                                    historical ? 0 : schema_id_, std::nullopt, 
std::nullopt,
+                                    std::nullopt, std::nullopt, std::nullopt));
+        entries.emplace_back(FileKind::Add(), BinaryRow::EmptyRow(), 
historical ? id : bucket,
+                             kNumBuckets, file);
+    }
+    ASSERT_OK_AND_ASSIGN(std::shared_ptr<FileFormat> format, 
FileFormatFactory::Get("avro", {}));
+    ASSERT_OK_AND_ASSIGN(
+        std::shared_ptr<FileStorePathFactory> paths,
+        FileStorePathFactory::Create(dir->Str(), initial->schema_, {}, "", 
"avro", "data-", true,
+                                     {}, std::nullopt, false, pool_));
+    ASSERT_OK(fs->Mkdirs(FileStorePathFactory::ManifestPath(dir->Str())));
+    for (bool cache_enabled : {false, true}) {
+        SCOPED_TRACE(cache_enabled);
+        ASSERT_OK_AND_ASSIGN(CoreOptions manifest_options, 
CoreOptions::FromMap({}));
+        if (cache_enabled) {
+            manifest_options.WithCache(std::make_shared<LruCache>(16 * 1024 * 
1024));
+        }
+        ASSERT_OK_AND_ASSIGN(std::shared_ptr<ManifestFile> manifest,
+                             ManifestFile::Create(fs, format, "null", paths, 
1024 * 1024, pool_,
+                                                  manifest_options, 
arrow::schema({})));
+        ASSERT_OK_AND_ASSIGN(std::vector<ManifestFileMeta> metas, 
manifest->Write(entries));
+        ASSERT_EQ(metas.size(), 1);
+        ASSERT_EQ(metas[0].SchemaId(), schema_id_);
+        auto serializer = 
std::make_unique<CountingManifestEntrySerializer>(pool_);
+        auto* counter = serializer.get();
+        manifest->serializer_ = std::move(serializer);
+        for (bool lazy_decode : {false, true}) {
+            SCOPED_TRACE(lazy_decode);
+            options_[Options::SCAN_MANIFEST_ENTRY_LAZY_DECODE_ENABLED] =
+                lazy_decode ? "true" : "false";
+            ASSERT_OK_AND_ASSIGN(std::unique_ptr<AppendOnlyFileStoreScan> scan,
+                                 CreateScan(predicate));
+            scan->manifest_file_ = manifest;
+            std::vector<ManifestEntry> baseline;
+            ASSERT_OK(scan->ReadAndMergeFileEntries(metas, &baseline));
+            ASSERT_EQ(baseline.size(), 2);
+            // Keep schema reads used by stats evolution out of the 
bucket-read measurement.
+            scan->schema_manager_ = std::make_shared<SchemaManager>(fs, 
dir->Str());
+            counter->decoded_entries = 0;
+            fs->read_count = 0;
+            std::vector<ManifestEntry> candidates;
+            ASSERT_OK(scan->ReadAndMergeBucketFileEntries(metas, bucket, 
&candidates));
+            ASSERT_EQ(fs->read_count, 0);
+            ASSERT_EQ(counter->decoded_entries.load(),
+                      entries.size() - (lazy_decode ? kNumBuckets - 1 : 0));
+            std::vector<ManifestEntry> filtered;
+            for (const auto& entry : candidates) {
+                ASSERT_OK_AND_ASSIGN(bool keep, 
scan->FilterManifestEntry(entry));
+                if (keep) {
+                    filtered.push_back(entry);
+                }
+            }
+            ASSERT_EQ(filtered, baseline);
+        }
+    }
+}
+
 TEST_F(AppendBucketPruningTest, PreservesExplicitBucketFilter) {
     BinaryRow key = BinaryRowGenerator::GenerateRow({std::string("key")}, 
pool_.get());
     int32_t other_bucket = (DefaultBucketFunction().Bucket(key, kNumBuckets) + 
1) % kNumBuckets;
@@ -187,17 +500,26 @@ TEST_F(AppendBucketPruningTest, 
PrunesCompatibleHistoricalSchema) {
     CheckBuckets(KeyEquals(), DefaultBucketFunction().Bucket(key, 
kNumBuckets));
 }
 
-TEST_F(AppendBucketPruningTest, PreservesIncompatibleHistoricalBucketKeys) {
-    auto test_dir = UniqueTestDirectory::Create("local");
-    schema_manager_ =
-        std::make_shared<SchemaManager>(std::make_shared<LocalFileSystem>(), 
test_dir->Str());
-    ASSERT_OK_AND_ASSIGN(auto scan, CreateScan(nullptr));
-    ASSERT_OK(schema_manager_->CreateTable(scan->schema_, {}, {}, options_));
-    schema_id_ = 1;
-    rowkey_type_ = arrow::binary();
-    auto predicate = PredicateBuilder::Equal(0, "rowkey", FieldType::BINARY,
-                                             Literal(FieldType::BINARY, "key", 
3));
-    CheckBuckets(predicate, std::nullopt);
+TEST_F(AppendBucketPruningTest, PrunesHistoricalBucketsWithoutReadingSchemas) {
+    auto dir = UniqueTestDirectory::Create();
+    ASSERT_TRUE(dir);
+    auto fs = std::make_shared<CountingSchemaFileSystem>();
+    schema_manager_ = std::make_shared<SchemaManager>(fs, dir->Str());
+    schema_id_ = 1000;
+    ASSERT_OK_AND_ASSIGN(std::unique_ptr<AppendOnlyFileStoreScan> scan, 
CreateScan(KeyEquals()));
+    const int32_t other_bucket = (scan->bucket_selector_->Bucket(kNumBuckets) 
+ 1) % kNumBuckets;
+    for (int64_t data_schema_id : {0, 500, 999, 1000}) {
+        ASSERT_OK_AND_ASSIGN(
+            std::shared_ptr<DataFileMeta> file,
+            DataFileMeta::ForAppend("data.parquet", 100, 10, 
SimpleStats::EmptyStats(), 0, 9,
+                                    data_schema_id, std::nullopt, 
std::nullopt, std::nullopt,
+                                    std::nullopt, std::nullopt));
+        ManifestEntry entry(FileKind::Add(), BinaryRow::EmptyRow(), 
other_bucket, kNumBuckets,
+                            file);
+        ASSERT_OK_AND_ASSIGN(bool keep, scan->FilterManifestEntry(entry));
+        ASSERT_FALSE(keep);
+    }
+    ASSERT_EQ(fs->read_count, 0);
 }
 
 TEST_F(AppendBucketPruningTest, PreservesCrossScaleDecimalMatch) {
diff --git a/src/paimon/core/operation/file_store_scan.cpp 
b/src/paimon/core/operation/file_store_scan.cpp
index 89c137f3..45376b22 100644
--- a/src/paimon/core/operation/file_store_scan.cpp
+++ b/src/paimon/core/operation/file_store_scan.cpp
@@ -356,7 +356,7 @@ Status FileStoreScan::ReadManifestEntries(const 
std::vector<ManifestFileMeta>& m
 
 // Cache merged live manifest entries for one bucket before applying scan 
filters. Each cache value
 // keeps bounded snapshot results for a table/branch/bucket. Inferred entries 
also include other
-// bucket counts and schema IDs, with the current count and schema in the 
cache key. Exact hits
+// bucket counts, with the current count and schema in the cache key. Exact 
hits
 // can be returned directly; cache misses rebuild the target snapshot bucket 
from the target
 // snapshot's data manifests.
 Status FileStoreScan::ReadManifestEntriesWithCache(
@@ -447,15 +447,26 @@ Status FileStoreScan::ReadAndMergeBucketFileEntries(
     const std::vector<ManifestFileMeta>& manifest_metas, int32_t bucket,
     std::vector<ManifestEntry>* merged_entries) const {
     const bool inferred_bucket = !bucket_filter_ && bucket_selector_ != 
nullptr;
-    // Explicit-bucket lazy decoding cannot retain entries with a different 
layout.
-    if (!inferred_bucket && 
core_options_.ScanManifestEntryLazyDecodeEnabled()) {
+    if (core_options_.ScanManifestEntryLazyDecodeEnabled()) {
         std::vector<std::future<Result<std::vector<ManifestEntry>>>> futures;
         futures.reserve(manifest_metas.size());
         for (const auto& meta : manifest_metas) {
-            auto read_meta_task = [this, meta, bucket]() -> 
Result<std::vector<ManifestEntry>> {
+            auto read_meta_task = [this, meta, bucket,
+                                   inferred_bucket]() -> 
Result<std::vector<ManifestEntry>> {
                 std::vector<ManifestEntry> bucket_entries;
-                PAIMON_RETURN_NOT_OK(manifest_file_->ReadBucketEntries(
-                    meta.FileName(), bucket, meta.FileSize(), 
&bucket_entries));
+                if (meta.MinBucket() && meta.MaxBucket() && 
meta.MinBucket().value() == bucket &&
+                    meta.MaxBucket().value() == bucket) {
+                    // Every entry belongs to this bucket; a projection pass 
cannot prune rows.
+                    PAIMON_RETURN_NOT_OK(manifest_file_->Read(meta.FileName(), 
/*filter=*/nullptr,
+                                                              meta.FileSize(), 
&bucket_entries));
+                } else if (inferred_bucket) {
+                    PAIMON_RETURN_NOT_OK(manifest_file_->ReadBucketEntries(
+                        meta.FileName(), bucket, core_options_.GetBucket(), 
meta.FileSize(),
+                        &bucket_entries));
+                } else {
+                    PAIMON_RETURN_NOT_OK(manifest_file_->ReadBucketEntries(
+                        meta.FileName(), bucket, std::nullopt, 
meta.FileSize(), &bucket_entries));
+                }
                 return bucket_entries;
             };
             futures.push_back(Via(executor_.get(), read_meta_task));
@@ -481,8 +492,7 @@ Status FileStoreScan::ReadAndMergeBucketFileEntries(
     unmerged_entries.reserve(entries.size());
     for (auto& entry : entries) {
         if (entry.Bucket() == bucket ||
-            (inferred_bucket && (entry.TotalBuckets() != 
core_options_.GetBucket() ||
-                                 entry.File()->schema_id != 
table_schema_->Id()))) {
+            (inferred_bucket && entry.TotalBuckets() != 
core_options_.GetBucket())) {
             unmerged_entries.emplace_back(std::move(entry));
         }
     }
@@ -598,11 +608,9 @@ Result<bool> FileStoreScan::FilterManifestEntry(const 
ManifestEntry& entry) cons
     if (bucket_filter_ != std::nullopt && entry.Bucket() != 
bucket_filter_.value()) {
         return false;
     }
-    if (bucket_selector_ && entry.TotalBuckets() > 0) {
-        PAIMON_ASSIGN_OR_RAISE(bool compatible, 
HasCompatibleBucketKeys(entry.File()->schema_id));
-        if (compatible && entry.Bucket() != 
bucket_selector_->Bucket(entry.TotalBuckets())) {
-            return false;
-        }
+    if (bucket_selector_ && entry.TotalBuckets() > 0 &&
+        entry.Bucket() != bucket_selector_->Bucket(entry.TotalBuckets())) {
+        return false;
     }
     if (level_filter_ != nullptr && !level_filter_(entry.Level())) {
         return false;
@@ -610,31 +618,6 @@ Result<bool> FileStoreScan::FilterManifestEntry(const 
ManifestEntry& entry) cons
     return FilterByStats(entry);
 }
 
-Result<bool> FileStoreScan::HasCompatibleBucketKeys(int64_t data_schema_id) 
const {
-    if (data_schema_id == table_schema_->Id()) {
-        return true;
-    }
-    auto cached = bucket_schema_compatibility_.Find(data_schema_id);
-    if (cached) {
-        return cached.value();
-    }
-    PAIMON_ASSIGN_OR_RAISE(std::shared_ptr<TableSchema> data_schema,
-                           schema_manager_->ReadSchema(data_schema_id));
-    const auto& current_keys = table_schema_->BucketKeys();
-    const auto& data_keys = data_schema->BucketKeys();
-    PAIMON_ASSIGN_OR_RAISE(CoreOptions data_options, 
CoreOptions::FromMap(data_schema->Options()));
-    bool compatible = current_keys.size() == data_keys.size() &&
-                      core_options_.GetBucketFunctionType() == 
data_options.GetBucketFunctionType();
-    for (size_t i = 0; compatible && i < current_keys.size(); ++i) {
-        PAIMON_ASSIGN_OR_RAISE(DataField current_field, 
table_schema_->GetField(current_keys[i]));
-        PAIMON_ASSIGN_OR_RAISE(DataField data_field, 
data_schema->GetField(data_keys[i]));
-        compatible = current_field.Id() == data_field.Id() &&
-                     current_field.Type()->Equals(data_field.Type());
-    }
-    bucket_schema_compatibility_.Insert(data_schema_id, compatible);
-    return compatible;
-}
-
 Status FileStoreScan::SplitAndSetFilter(const std::vector<std::string>& 
partition_keys,
                                         const std::shared_ptr<arrow::Schema>& 
arrow_schema,
                                         const std::shared_ptr<ScanFilter>& 
scan_filters) {
diff --git a/src/paimon/core/operation/file_store_scan.h 
b/src/paimon/core/operation/file_store_scan.h
index d5ee9215..0eef2909 100644
--- a/src/paimon/core/operation/file_store_scan.h
+++ b/src/paimon/core/operation/file_store_scan.h
@@ -34,7 +34,6 @@
 #include "paimon/common/predicate/leaf_predicate_impl.h"
 #include "paimon/common/predicate/literal_converter.h"
 #include "paimon/common/predicate/predicate_filter.h"
-#include "paimon/common/utils/concurrent_hash_map.h"
 #include "paimon/common/utils/field_type_utils.h"
 #include "paimon/common/utils/linked_hash_map.h"
 #include "paimon/core/bucket/bucket_select_converter.h"
@@ -303,7 +302,6 @@ class FileStoreScan {
                                 std::vector<ManifestEntry>* entries) const;
 
     Result<bool> FilterManifestEntry(const ManifestEntry& entry) const;
-    Result<bool> HasCompatibleBucketKeys(int64_t data_schema_id) const;
 
  protected:
     std::shared_ptr<MemoryPool> pool_;
@@ -326,7 +324,6 @@ class FileStoreScan {
     std::shared_ptr<Executor> executor_;
     std::optional<int32_t> bucket_filter_;
     std::unique_ptr<BucketSelector> bucket_selector_;
-    mutable ConcurrentHashMap<int64_t, bool> bucket_schema_compatibility_;
     std::function<bool(int32_t)> level_filter_;
     std::optional<Snapshot> specified_snapshot_;
     std::shared_ptr<Metrics> metrics_;
diff --git a/src/paimon/core/snapshot_file_scan.cpp 
b/src/paimon/core/snapshot_file_scan.cpp
index 9d80ec86..c7f854b8 100644
--- a/src/paimon/core/snapshot_file_scan.cpp
+++ b/src/paimon/core/snapshot_file_scan.cpp
@@ -182,9 +182,12 @@ class SnapshotFileCollector {
             }
             futures.push_back(Via(executor_.get(), [this, meta]() -> 
Result<ManifestReadResult> {
                 std::vector<ManifestEntry> entries;
-                if (bucket_id_) {
-                    PAIMON_RETURN_NOT_OK(manifest_file_->ReadBucketEntries(
-                        meta.FileName(), bucket_id_.value(), meta.FileSize(), 
&entries));
+                const bool all_in_bucket =
+                    bucket_id_ && meta.MinBucket() == bucket_id_ && 
meta.MaxBucket() == bucket_id_;
+                if (bucket_id_ && !all_in_bucket) {
+                    PAIMON_RETURN_NOT_OK(
+                        manifest_file_->ReadBucketEntries(meta.FileName(), 
bucket_id_.value(),
+                                                          std::nullopt, 
meta.FileSize(), &entries));
                 } else {
                     PAIMON_RETURN_NOT_OK(manifest_file_->Read(meta.FileName(), 
/*filter=*/nullptr,
                                                               meta.FileSize(), 
&entries));
diff --git a/src/paimon/core/snapshot_file_scan_test.cpp 
b/src/paimon/core/snapshot_file_scan_test.cpp
index e6629fbd..b0363376 100644
--- a/src/paimon/core/snapshot_file_scan_test.cpp
+++ b/src/paimon/core/snapshot_file_scan_test.cpp
@@ -20,6 +20,7 @@
 #include "paimon/snapshot/snapshot_file_scan.h"
 
 #include <algorithm>
+#include <cstdint>
 #include <initializer_list>
 #include <map>
 #include <memory>
@@ -31,12 +32,15 @@
 #include <vector>
 
 #include "gtest/gtest.h"
+#include "paimon/common/factories/io_hook.h"
 #include "paimon/common/utils/path_util.h"
+#include "paimon/common/utils/scope_guard.h"
 #include "paimon/common/utils/string_utils.h"
 #include "paimon/defs.h"
 #include "paimon/fs/local/local_file_system.h"
 #include "paimon/predicate/predicate_builder.h"
 #include "paimon/scan_context.h"
+#include "paimon/testing/utils/test_helper.h"
 #include "paimon/testing/utils/testharness.h"
 
 namespace paimon::test {
@@ -151,6 +155,37 @@ TEST(SnapshotFileScanTest, 
TestLatestSnapshotAndBucketFilter) {
         bucket_one_files);
 }
 
+TEST(SnapshotFileScanTest, TestSingleBucketManifestAvoidsExtraIO) {
+    auto dir = UniqueTestDirectory::Create();
+    ASSERT_TRUE(dir);
+    auto schema = arrow::schema({arrow::field("value", arrow::utf8())});
+    ASSERT_OK_AND_ASSIGN(
+        std::unique_ptr<TestHelper> helper,
+        TestHelper::Create(
+            dir->Str(), schema, {}, {},
+            {{Options::FILE_FORMAT, "orc"}, {Options::BUCKET, "1"}, 
{Options::BUCKET_KEY, "value"}},
+            false));
+    ASSERT_OK_AND_ASSIGN(
+        std::unique_ptr<RecordBatch> batch,
+        TestHelper::MakeRecordBatch(arrow::struct_(schema->fields()), 
R"([["value"]])", {}, 0, {}));
+    ASSERT_OK(helper->WriteAndCommit(std::move(batch), 0, std::nullopt));
+    helper.reset();
+    const std::string table_path = dir->Str() + "/foo.db/bar";
+
+    auto* io_hook = IOHook::GetInstance();
+    ScopeGuard guard([io_hook]() { io_hook->Clear(); });
+    io_hook->Reset(-1, IOHook::Mode::SILENT);
+    ASSERT_OK_AND_ASSIGN(std::set<std::string> all_files, 
ListFiles(table_path));
+    const int64_t full_read_io_count = io_hook->IOCount();
+    ASSERT_GT(full_read_io_count, 0);
+
+    io_hook->Reset(-1, IOHook::Mode::SILENT);
+    ASSERT_OK_AND_ASSIGN(std::set<std::string> bucket_files,
+                         ListFiles(table_path, std::nullopt, CreateFilter({}, 
0)));
+    ASSERT_EQ(bucket_files, all_files);
+    ASSERT_EQ(io_hook->IOCount(), full_read_io_count);
+}
+
 TEST(SnapshotFileScanTest, TestExplicitSnapshot) {
     std::string table_path = GetDataDir() + "/orc/append_09.db/append_09";
 
diff --git a/src/paimon/core/utils/objects_file.h 
b/src/paimon/core/utils/objects_file.h
index 845efd41..e65ee75a 100644
--- a/src/paimon/core/utils/objects_file.h
+++ b/src/paimon/core/utils/objects_file.h
@@ -92,10 +92,12 @@ class ObjectsFile {
         return Status::OK();
     }
 
+    // Optional preparation may wrap the reader and reread the file, using 
either cached bytes
+    // or the underlying file stream.
     Status ReadArrowBatches(
-        const std::string& file_name,
+        const std::string& file_name, std::optional<int64_t> file_size,
         const std::function<Status(const 
std::shared_ptr<arrow::StructArray>&)>& consumer,
-        std::optional<int64_t> file_size) const;
+        const std::function<Status(std::unique_ptr<FileBatchReader>*)>& 
prepare_reader) const;
 
     std::shared_ptr<PathFactory> path_factory_;
     std::shared_ptr<MemoryPool> pool_;
@@ -158,7 +160,7 @@ Status ObjectsFile<T>::Read(const std::string& file_name,
                             const std::function<Result<bool>(const T&)>& 
filter,
                             std::optional<int64_t> file_size, std::vector<T>* 
result) const {
     return ReadArrowBatches(
-        file_name,
+        file_name, file_size,
         [this, &filter, result](const std::shared_ptr<arrow::StructArray>& 
struct_array) -> Status {
             result->reserve(result->size() + struct_array->length());
             const arrow::ArrayVector& fields = struct_array->fields();
@@ -177,14 +179,14 @@ Status ObjectsFile<T>::Read(const std::string& file_name,
             }
             return Status::OK();
         },
-        file_size);
+        /*prepare_reader=*/nullptr);
 }
 
 template <typename T>
 Status ObjectsFile<T>::ReadArrowBatches(
-    const std::string& file_name,
+    const std::string& file_name, std::optional<int64_t> file_size,
     const std::function<Status(const std::shared_ptr<arrow::StructArray>&)>& 
consumer,
-    std::optional<int64_t> file_size) const {
+    const std::function<Status(std::unique_ptr<FileBatchReader>*)>& 
prepare_reader) const {
     std::string file_path = path_factory_->ToPath(file_name);
     std::shared_ptr<InputStream> file_input_stream;
     std::shared_ptr<Bytes> cached_bytes;
@@ -215,6 +217,9 @@ Status ObjectsFile<T>::ReadArrowBatches(
 
     PAIMON_ASSIGN_OR_RAISE(std::unique_ptr<FileBatchReader> batch_reader,
                            reader_builder_->Build(file_input_stream));
+    if (prepare_reader) {
+        PAIMON_RETURN_NOT_OK(prepare_reader(&batch_reader));
+    }
     auto reader = std::make_unique<ManifestMetaReader>(std::move(batch_reader),
                                                        
serializer_->GetDataType(), arrow_pool_);
     while (true) {
diff --git a/src/paimon/format/avro/avro_direct_decoder.cpp 
b/src/paimon/format/avro/avro_direct_decoder.cpp
index 9f9e3642..9f2d5763 100644
--- a/src/paimon/format/avro/avro_direct_decoder.cpp
+++ b/src/paimon/format/avro/avro_direct_decoder.cpp
@@ -466,6 +466,10 @@ Status AvroDirectDecoder::DecodeAvroToBuilder(const 
::avro::NodePtr& avro_node,
     return DecodeFieldToBuilder(avro_node, projection, decoder, array_builder, 
ctx);
 }
 
+Status AvroDirectDecoder::SkipValue(const ::avro::NodePtr& avro_node, 
::avro::Decoder* decoder) {
+    return SkipAvroValue(avro_node, decoder);
+}
+
 Status AvroDirectDecoder::ReserveBuilderCapacity(int64_t capacity,
                                                  arrow::ArrayBuilder* 
array_builder) {
     return ReserveBuilderCapacityImpl(capacity, array_builder);
diff --git a/src/paimon/format/avro/avro_direct_decoder.h 
b/src/paimon/format/avro/avro_direct_decoder.h
index 3b316f54..ad5cdd9e 100644
--- a/src/paimon/format/avro/avro_direct_decoder.h
+++ b/src/paimon/format/avro/avro_direct_decoder.h
@@ -82,6 +82,9 @@ class AvroDirectDecoder {
                                       ::avro::Decoder* decoder, 
arrow::ArrayBuilder* array_builder,
                                       DecodeContext* ctx);
 
+    /// Skip one value without materializing it in an Arrow builder.
+    static Status SkipValue(const ::avro::NodePtr& avro_node, ::avro::Decoder* 
decoder);
+
     /// Reserve slots for a builder and any struct children with the same 
cardinality.
     /// @param capacity Number of additional values to append.
     /// @param array_builder Builder to reserve.
diff --git a/src/paimon/format/avro/avro_file_batch_reader.cpp 
b/src/paimon/format/avro/avro_file_batch_reader.cpp
index 252f563c..de3c2837 100644
--- a/src/paimon/format/avro/avro_file_batch_reader.cpp
+++ b/src/paimon/format/avro/avro_file_batch_reader.cpp
@@ -18,7 +18,8 @@
 
 #include "paimon/format/avro/avro_file_batch_reader.h"
 
-#include <limits>
+#include <algorithm>
+#include <cassert>
 #include <memory>
 #include <utility>
 
@@ -101,25 +102,26 @@ Result<std::unique_ptr<::avro::DataFileReaderBase>> 
AvroFileBatchReader::CreateD
 }
 
 Result<BatchReader::ReadBatch> AvroFileBatchReader::NextBatch() {
-    if (next_row_to_read_ == std::numeric_limits<uint64_t>::max()) {
-        next_row_to_read_ = 0;
-    }
+    previous_first_row_ = next_row_to_read_;
+    previous_row_ids_.clear();
+    previous_batch_row_count_ = 0;
     try {
         while (array_builder_->length() < batch_size_) {
-            if (!reader_->hasMore()) {
+            const std::optional<uint64_t> target =
+                selection_ ? selection_->NextRow() : 
std::optional<uint64_t>(next_row_to_read_);
+            if (!target) {
+                break;
+            }
+            PAIMON_ASSIGN_OR_RAISE(bool available, 
AdvanceToRow(target.value()));
+            if (!available) {
                 break;
             }
-            if (array_builder_->length() == 0) {
-                PAIMON_RETURN_NOT_OK(
-                    AvroDirectDecoder::ReserveBuilderCapacity(batch_size_, 
array_builder_.get()));
+            PAIMON_RETURN_NOT_OK(ReadCurrentRow(/*materialize=*/true));
+            if (selection_) {
+                previous_row_ids_.push_back(target.value());
+                selection_->Advance();
             }
-            reader_->decr();
-            PAIMON_RETURN_NOT_OK(AvroDirectDecoder::DecodeAvroToBuilder(
-                reader_->dataSchema().root(), read_fields_projection_, 
&reader_->decoder(),
-                array_builder_.get(), &decode_context_));
         }
-        previous_first_row_ = next_row_to_read_;
-        next_row_to_read_ += array_builder_->length();
         if (array_builder_->length() == 0) {
             previous_batch_row_count_ = 0;
             return BatchReader::MakeEofBatch();
@@ -145,6 +147,108 @@ Result<BatchReader::ReadBatch> 
AvroFileBatchReader::NextBatch() {
     }
 }
 
+std::optional<uint64_t> AvroFileBatchReader::SelectionCursor::NextRow() const {
+    if (next_ == end_) {
+        return std::nullopt;
+    }
+    return static_cast<uint32_t>(*next_);
+}
+
+void AvroFileBatchReader::BlockIndex::Observe(uint64_t row, int64_t 
file_offset) {
+    if (state_ != State::kBuilding ||
+        (!blocks_.empty() && blocks_.back().file_offset == file_offset)) {
+        return;
+    }
+    if (blocks_.size() == kMaxBlocks) {
+        blocks_.clear();
+        state_ = State::kDisabled;
+        return;
+    }
+    blocks_.push_back({row, file_offset});
+}
+
+void AvroFileBatchReader::BlockIndex::Finish(uint64_t row_count) {
+    if (state_ == State::kBuilding) {
+        row_count_ = row_count;
+        state_ = State::kReady;
+    }
+}
+
+void AvroFileBatchReader::BlockIndex::Reset() {
+    if (state_ == State::kBuilding) {
+        blocks_.clear();
+    }
+}
+
+std::optional<uint64_t> AvroFileBatchReader::BlockIndex::RowCount() const {
+    return state_ == State::kReady ? std::optional<uint64_t>(row_count_) : 
std::nullopt;
+}
+
+std::optional<AvroFileBatchReader::BlockIndex::Position> 
AvroFileBatchReader::BlockIndex::Locate(
+    uint64_t row) const {
+    if (state_ != State::kReady || row >= row_count_ || blocks_.empty()) {
+        return std::nullopt;
+    }
+    auto block = std::upper_bound(
+        blocks_.begin(), blocks_.end(), row,
+        [](uint64_t target, const Position& position) { return target < 
position.first_row; });
+    return *(block - 1);
+}
+
+Result<bool> AvroFileBatchReader::AdvanceToRow(uint64_t row) {
+    assert(row >= next_row_to_read_);
+    const std::optional<uint64_t> row_count = block_index_.RowCount();
+    if (row_count && row >= row_count.value()) {
+        return false;
+    }
+    if (row > next_row_to_read_) {
+        const auto block = block_index_.Locate(row);
+        if (block && block->first_row > next_row_to_read_) {
+            reader_->seek(block->file_offset);
+            next_row_to_read_ = block->first_row;
+        }
+    }
+    while (next_row_to_read_ < row) {
+        if (!PrepareNextRow()) {
+            return false;
+        }
+        PAIMON_RETURN_NOT_OK(ReadCurrentRow(/*materialize=*/false));
+    }
+    return PrepareNextRow();
+}
+
+bool AvroFileBatchReader::PrepareNextRow() {
+    if (!reader_->hasMore()) {
+        if (!selection_) {
+            block_index_.Finish(next_row_to_read_);
+        }
+        return false;
+    }
+    // Preserve index construction during full reads; bitmap reads only 
consume an existing index.
+    if (!selection_) {
+        block_index_.Observe(next_row_to_read_, reader_->previousSync());
+    }
+    return true;
+}
+
+Status AvroFileBatchReader::ReadCurrentRow(bool materialize) {
+    reader_->decr();
+    if (materialize) {
+        if (array_builder_->length() == 0) {
+            PAIMON_RETURN_NOT_OK(
+                AvroDirectDecoder::ReserveBuilderCapacity(batch_size_, 
array_builder_.get()));
+        }
+        PAIMON_RETURN_NOT_OK(AvroDirectDecoder::DecodeAvroToBuilder(
+            reader_->dataSchema().root(), read_fields_projection_, 
&reader_->decoder(),
+            array_builder_.get(), &decode_context_));
+    } else {
+        PAIMON_RETURN_NOT_OK(
+            AvroDirectDecoder::SkipValue(reader_->dataSchema().root(), 
&reader_->decoder()));
+    }
+    ++next_row_to_read_;
+    return Status::OK();
+}
+
 Status AvroFileBatchReader::SetReadSchema(::ArrowSchema* read_schema,
                                           const std::shared_ptr<Predicate>& 
predicate,
                                           const 
std::optional<RoaringBitmap32>& selection_bitmap) {
@@ -152,9 +256,6 @@ Status AvroFileBatchReader::SetReadSchema(::ArrowSchema* 
read_schema,
         return Status::Invalid("SetReadSchema failed: read schema cannot be 
nullptr");
     }
     // TODO(menglingda.mld): support predicate
-    if (selection_bitmap) {
-        // TODO(menglingda.mld): support bitmap
-    }
     PAIMON_ASSIGN_OR_RAISE_FROM_ARROW(std::shared_ptr<arrow::Schema> 
arrow_read_schema,
                                       arrow::ImportSchema(read_schema));
     PAIMON_ASSIGN_OR_RAISE(std::shared_ptr<arrow::Schema> file_schema,
@@ -180,9 +281,15 @@ Status AvroFileBatchReader::SetReadSchema(::ArrowSchema* 
read_schema,
     reader_ = std::move(reader);
     array_builder_ = std::move(array_builder);
     decode_context_.ClearBuilderMetadata();
-    previous_first_row_ = std::numeric_limits<uint64_t>::max();
+    selection_.reset();
+    if (selection_bitmap) {
+        selection_.emplace(selection_bitmap.value());
+    }
+    block_index_.Reset();
+    previous_row_ids_.clear();
+    previous_first_row_.reset();
     previous_batch_row_count_ = 0;
-    next_row_to_read_ = std::numeric_limits<uint64_t>::max();
+    next_row_to_read_ = 0;
     close_ = false;
     return Status::OK();
 }
@@ -212,6 +319,9 @@ Result<std::unique_ptr<::ArrowSchema>> 
AvroFileBatchReader::GetFileSchema() cons
 }
 
 Result<uint64_t> AvroFileBatchReader::GetNumberOfRows() const {
+    if (const auto row_count = block_index_.RowCount()) {
+        return row_count.value();
+    }
     if (!total_rows_) {
         PAIMON_ASSIGN_OR_RAISE(int64_t current_pos, input_stream_->GetPos());
         ScopeGuard stream_guard([this, current_pos]() -> void {
diff --git a/src/paimon/format/avro/avro_file_batch_reader.h 
b/src/paimon/format/avro/avro_file_batch_reader.h
index 05bdf912..3c0fb6d7 100644
--- a/src/paimon/format/avro/avro_file_batch_reader.h
+++ b/src/paimon/format/avro/avro_file_batch_reader.h
@@ -18,8 +18,9 @@
 
 #pragma once
 
-#include <limits>
+#include <cstdint>
 #include <memory>
+#include <optional>
 #include <set>
 #include <utility>
 #include <vector>
@@ -52,7 +53,7 @@ class AvroFileBatchReader : public FileBatchReader {
 
     Result<uint64_t> GetPreviousBatchFileRowId(uint64_t batch_row_id) const 
override {
         if (previous_batch_row_count_ == 0) {
-            if (previous_first_row_ == std::numeric_limits<uint64_t>::max()) {
+            if (!previous_first_row_) {
                 return Status::Invalid("No batch has been read yet.");
             } else {
                 return Status::Invalid("Last batch was EOF.");
@@ -63,7 +64,8 @@ class AvroFileBatchReader : public FileBatchReader {
                 fmt::format("batch_row_id {} is out of range, last batch row 
count is {}",
                             batch_row_id, previous_batch_row_count_));
         }
-        return previous_first_row_ + batch_row_id;
+        return selection_ ? previous_row_ids_[batch_row_id]
+                          : previous_first_row_.value() + batch_row_id;
     }
 
     Result<uint64_t> GetNumberOfRows() const override;
@@ -77,10 +79,55 @@ class AvroFileBatchReader : public FileBatchReader {
     }
 
     bool SupportPreciseBitmapSelection() const override {
-        return false;
+        return true;
     }
 
  private:
+    class SelectionCursor {
+     public:
+        explicit SelectionCursor(const RoaringBitmap32& bitmap)
+            : bitmap_(bitmap), next_(bitmap_.Begin()), end_(bitmap_.End()) {}
+        SelectionCursor(const SelectionCursor&) = delete;
+        SelectionCursor& operator=(const SelectionCursor&) = delete;
+
+        std::optional<uint64_t> NextRow() const;
+
+        void Advance() {
+            ++next_;
+        }
+
+     private:
+        // Iterators must be destroyed before the bitmap they reference.
+        RoaringBitmap32 bitmap_;
+        RoaringBitmap32::Iterator next_;
+        RoaringBitmap32::Iterator end_;
+    };
+
+    class BlockIndex {
+     public:
+        struct Position {
+            uint64_t first_row;
+            int64_t file_offset;
+        };
+
+        void Observe(uint64_t row, int64_t file_offset);
+        void Finish(uint64_t row_count);
+        void Reset();
+        std::optional<Position> Locate(uint64_t row) const;
+        std::optional<uint64_t> RowCount() const;
+
+     private:
+        enum class State { kBuilding, kReady, kDisabled };
+        static constexpr size_t kMaxBlocks = 64 * 1024;
+        State state_ = State::kBuilding;
+        std::vector<Position> blocks_;
+        uint64_t row_count_ = 0;
+    };
+
+    Result<bool> AdvanceToRow(uint64_t row);
+    bool PrepareNextRow();
+    Status ReadCurrentRow(bool materialize);
+
     void DoClose();
 
     static Result<std::unique_ptr<::avro::DataFileReaderBase>> 
CreateDataFileReader(
@@ -105,8 +152,12 @@ class AvroFileBatchReader : public FileBatchReader {
     std::unique_ptr<::avro::DataFileReaderBase> reader_;
     std::unique_ptr<arrow::ArrayBuilder> array_builder_;
     std::optional<std::set<size_t>> read_fields_projection_;
-    uint64_t previous_first_row_ = std::numeric_limits<uint64_t>::max();
-    uint64_t next_row_to_read_ = std::numeric_limits<uint64_t>::max();
+    std::optional<SelectionCursor> selection_;
+    // File-level acceleration, independent of the current projection and 
bitmap.
+    BlockIndex block_index_;
+    std::vector<uint64_t> previous_row_ids_;
+    std::optional<uint64_t> previous_first_row_;
+    uint64_t next_row_to_read_ = 0;
     uint64_t previous_batch_row_count_ = 0;
     mutable std::optional<uint64_t> total_rows_ = std::nullopt;
     const int32_t batch_size_;
diff --git a/src/paimon/format/avro/avro_file_batch_reader_test.cpp 
b/src/paimon/format/avro/avro_file_batch_reader_test.cpp
index bd0d63eb..6a61c4c9 100644
--- a/src/paimon/format/avro/avro_file_batch_reader_test.cpp
+++ b/src/paimon/format/avro/avro_file_batch_reader_test.cpp
@@ -18,6 +18,8 @@
 
 #include "paimon/format/avro/avro_file_batch_reader.h"
 
+#include <algorithm>
+#include <limits>
 #include <memory>
 #include <string>
 #include <utility>
@@ -25,19 +27,44 @@
 #include "arrow/api.h"
 #include "arrow/c/bridge.h"
 #include "arrow/ipc/api.h"
+#include "avro/Compiler.hh"
 #include "gtest/gtest.h"
+#include "paimon/common/utils/checked_cast.h"
 #include "paimon/common/utils/path_util.h"
 #include "paimon/core/manifest/manifest_file.h"
 #include "paimon/core/manifest/manifest_list.h"
 #include "paimon/format/file_format.h"
 #include "paimon/format/file_format_factory.h"
 #include "paimon/fs/local/local_file_system.h"
+#include "paimon/io/byte_array_input_stream.h"
 #include "paimon/testing/utils/read_result_collector.h"
 #include "paimon/testing/utils/testharness.h"
 #include "paimon/testing/utils/timezone_guard.h"
 
 namespace paimon::avro::test {
 
+class CountingInputStream : public ByteArrayInputStream {
+ public:
+    explicit CountingInputStream(const std::string& bytes)
+        : ByteArrayInputStream(bytes.data(), bytes.size()) {}
+
+    using ByteArrayInputStream::Read;
+
+    Result<int64_t> Read(char* buffer, int64_t size) override {
+        PAIMON_ASSIGN_OR_RAISE(int64_t count, 
ByteArrayInputStream::Read(buffer, size));
+        bytes_read += count;
+        return count;
+    }
+
+    Status Seek(int64_t offset, SeekOrigin origin) override {
+        ++seek_count;
+        return ByteArrayInputStream::Seek(offset, origin);
+    }
+
+    int64_t bytes_read = 0;
+    int32_t seek_count = 0;
+};
+
 class AvroFileBatchReaderTest : public ::testing::Test, public 
::testing::WithParamInterface<bool> {
  public:
     void SetUp() override {
@@ -328,6 +355,346 @@ TEST_F(AvroFileBatchReaderTest, 
TestSetReadSchemaRejectNestedSubFieldProjection)
                         "does not support nested sub-field projection");
 }
 
+TEST_F(AvroFileBatchReaderTest, TestPreciseBitmapSelectionAndReset) {
+    auto data_type = arrow::struct_(
+        {arrow::field("id", arrow::int32()),
+         arrow::field("payload",
+                      arrow::struct_({arrow::field("text", arrow::utf8()),
+                                      arrow::field("values", 
arrow::list(arrow::int32()))}))});
+    auto source =
+        arrow::ipc::internal::json::ArrayFromJSON(
+            data_type, 
R"([[0,["a",[1,2]]],[1,null],[2,["c",[]]],[3,["d",[4]]],[4,["e",null]]])")
+            .ValueOrDie();
+    const std::vector<std::vector<uint32_t>> selections = {
+        {}, {0}, {1, 3}, {0, 1, 2, 3, 4}, {2, 99}};
+    for (const std::string& compression : {std::string("null"), 
std::string("deflate")}) {
+        const std::string path = PathUtil::JoinPath(dir_->Str(), compression + 
".avro");
+        WriteData(source, path, compression);
+        for (int32_t batch_size : {1, 2, 8}) {
+            ASSERT_OK_AND_ASSIGN(std::shared_ptr<ReaderBuilder> builder,
+                                 
file_format_->CreateReaderBuilder(batch_size));
+            ASSERT_OK_AND_ASSIGN(std::shared_ptr<InputStream> input, 
fs_->Open(path));
+            ASSERT_OK_AND_ASSIGN(std::unique_ptr<FileBatchReader> reader, 
builder->Build(input));
+            ASSERT_TRUE(reader->SupportPreciseBitmapSelection());
+            for (const auto& ids : selections) {
+                RoaringBitmap32 selection;
+                for (uint32_t id : ids) {
+                    selection.Add(id);
+                }
+                ArrowSchema c_schema;
+                ASSERT_TRUE(
+                    arrow::ExportSchema(*arrow::schema(data_type->fields()), 
&c_schema).ok());
+                ASSERT_OK(reader->SetReadSchema(&c_schema, nullptr, 
selection));
+                ASSERT_NOK(reader->GetPreviousBatchFileRowId(0));
+                size_t cursor = 0;
+                while (true) {
+                    ASSERT_OK_AND_ASSIGN(BatchReader::ReadBatch batch, 
reader->NextBatch());
+                    if (BatchReader::IsEofBatch(batch)) {
+                        break;
+                    }
+                    auto array =
+                        arrow::ImportArray(batch.first.get(), 
batch.second.get()).ValueOrDie();
+                    ASSERT_GT(array->length(), 0);
+                    ASSERT_LE(array->length(), batch_size);
+                    for (int64_t i = 0; i < array->length(); ++i) {
+                        ASSERT_LT(cursor, ids.size());
+                        ASSERT_OK_AND_ASSIGN(uint64_t file_row,
+                                             
reader->GetPreviousBatchFileRowId(i));
+                        ASSERT_EQ(file_row, ids[cursor++]);
+                        ASSERT_TRUE(array->Slice(i, 
1)->Equals(source->Slice(file_row, 1)));
+                    }
+                    
ASSERT_NOK(reader->GetPreviousBatchFileRowId(array->length()));
+                }
+                ASSERT_EQ(cursor, static_cast<size_t>(std::count_if(
+                                      ids.begin(), ids.end(), [](uint32_t id) 
{ return id < 5; })));
+                ASSERT_NOK(reader->GetPreviousBatchFileRowId(0));
+            }
+            // Removing the bitmap must restore ordinary contiguous file row 
IDs.
+            ArrowSchema c_schema;
+            
ASSERT_TRUE(arrow::ExportSchema(*arrow::schema(data_type->fields()), 
&c_schema).ok());
+            ASSERT_OK(reader->SetReadSchema(&c_schema, nullptr, std::nullopt));
+            ASSERT_OK_AND_ASSIGN(BatchReader::ReadBatch batch, 
reader->NextBatch());
+            auto array = arrow::ImportArray(batch.first.get(), 
batch.second.get()).ValueOrDie();
+            ASSERT_TRUE(array->Equals(source->Slice(0, std::min(batch_size, 
5))));
+            ASSERT_OK_AND_ASSIGN(uint64_t first_row, 
reader->GetPreviousBatchFileRowId(0));
+            ASSERT_EQ(first_row, 0);
+        }
+    }
+}
+
+TEST_F(AvroFileBatchReaderTest, 
TestBitmapSelectionAcrossBlocksAfterProjection) {
+    auto data_type = arrow::struct_(
+        {arrow::field("id", arrow::int32()), arrow::field("payload", 
arrow::utf8())});
+    std::string json = "[";
+    for (int32_t i = 0; i < 128; ++i) {
+        if (i != 0) {
+            json += ",";
+        }
+        json += fmt::format(R"([{},"{}"])", i, std::string(16384, 'a' + i % 
26));
+    }
+    json += "]";
+    auto source = arrow::ipc::internal::json::ArrayFromJSON(data_type, 
json).ValueOrDie();
+    for (const std::string& compression : {std::string("null"), 
std::string("deflate")}) {
+        const std::string path = PathUtil::JoinPath(dir_->Str(), compression + 
".avro");
+        WriteData(source, path, compression);
+        // Fresh reader, partial projection, full projection, and an exhausted 
bitmap that
+        // stopped before physical EOF must all support the same subsequent 
selections.
+        for (int32_t preparation : {0, 1, 2, 3}) {
+            SCOPED_TRACE(preparation);
+            for (int32_t batch_size : {1, 7, 256}) {
+                ASSERT_OK_AND_ASSIGN(std::shared_ptr<ReaderBuilder> builder,
+                                     
file_format_->CreateReaderBuilder(batch_size));
+                ASSERT_OK_AND_ASSIGN(std::shared_ptr<InputStream> input, 
fs_->Open(path));
+                ASSERT_OK_AND_ASSIGN(std::unique_ptr<FileBatchReader> reader,
+                                     builder->Build(input));
+                if (preparation != 0) {
+                    ArrowSchema probe_schema;
+                    ASSERT_TRUE(
+                        
arrow::ExportSchema(*arrow::schema({data_type->field(0)}), &probe_schema)
+                            .ok());
+                    const std::optional<RoaringBitmap32> selection =
+                        preparation == 3
+                            ? 
std::optional<RoaringBitmap32>(RoaringBitmap32::From({0, 64}))
+                            : std::nullopt;
+                    ASSERT_OK(reader->SetReadSchema(&probe_schema, nullptr, 
selection));
+                    do {
+                        ASSERT_OK_AND_ASSIGN(BatchReader::ReadBatch batch, 
reader->NextBatch());
+                        if (BatchReader::IsEofBatch(batch)) {
+                            break;
+                        }
+                        ASSERT_TRUE(arrow::ImportArray(batch.first.get(), 
batch.second.get()).ok());
+                    } while (preparation != 1);
+                }
+                for (const std::vector<int32_t>& ids :
+                     std::vector<std::vector<int32_t>>{{},
+                                                       {127},
+                                                       {0, 1, 64, 65, 127},
+                                                       {31, 32, 33},
+                                                       {0, 128},
+                                                       {999},
+                                                       {0, 
std::numeric_limits<int32_t>::min()}}) {
+                    ArrowSchema full_schema;
+                    ASSERT_TRUE(
+                        
arrow::ExportSchema(*arrow::schema(data_type->fields()), &full_schema)
+                            .ok());
+                    ASSERT_OK(
+                        reader->SetReadSchema(&full_schema, nullptr, 
RoaringBitmap32::From(ids)));
+                    size_t cursor = 0;
+                    while (true) {
+                        ASSERT_OK_AND_ASSIGN(BatchReader::ReadBatch batch, 
reader->NextBatch());
+                        if (BatchReader::IsEofBatch(batch)) {
+                            break;
+                        }
+                        auto array =
+                            arrow::ImportArray(batch.first.get(), 
batch.second.get()).ValueOrDie();
+                        for (int64_t i = 0; i < array->length(); ++i) {
+                            ASSERT_LT(cursor, ids.size());
+                            ASSERT_OK_AND_ASSIGN(uint64_t row,
+                                                 
reader->GetPreviousBatchFileRowId(i));
+                            ASSERT_EQ(row, ids[cursor++]);
+                            ASSERT_TRUE(array->Slice(i, 
1)->Equals(source->Slice(row, 1)));
+                        }
+                    }
+                    ASSERT_EQ(cursor, std::count_if(ids.begin(), ids.end(), 
[](int32_t id) {
+                                  return static_cast<uint32_t>(id) < 128;
+                              }));
+                }
+                ArrowSchema full_schema;
+                ASSERT_TRUE(
+                    arrow::ExportSchema(*arrow::schema(data_type->fields()), 
&full_schema).ok());
+                ASSERT_OK(reader->SetReadSchema(&full_schema, nullptr, 
std::nullopt));
+                ASSERT_OK_AND_ASSIGN(
+                    std::shared_ptr<arrow::ChunkedArray> restored,
+                    
paimon::test::ReadResultCollector::CollectResult(reader.get()));
+                
ASSERT_TRUE(restored->Equals(std::make_shared<arrow::ChunkedArray>(source)));
+            }
+        }
+    }
+}
+
+TEST_F(AvroFileBatchReaderTest, TestBitmapSelectionAvoidsUnnecessaryReads) {
+    const std::string path = PathUtil::JoinPath(dir_->Str(), "indexed.avro");
+    auto data_type = arrow::struct_(
+        {arrow::field("id", arrow::int32()), arrow::field("payload", 
arrow::utf8())});
+    std::string json = "[";
+    for (int32_t i = 0; i < 256; ++i) {
+        if (i != 0) {
+            json += ",";
+        }
+        json += fmt::format(R"([{},"{}"])", i, std::string(16384, 'a' + i % 
26));
+    }
+    json += "]";
+    auto source = arrow::ipc::internal::json::ArrayFromJSON(data_type, 
json).ValueOrDie();
+    WriteData(source, path, "null");
+    std::string bytes;
+    ASSERT_OK(fs_->ReadFile(path, &bytes));
+    auto input = std::make_shared<CountingInputStream>(bytes);
+    ASSERT_OK_AND_ASSIGN(std::shared_ptr<ReaderBuilder> builder,
+                         file_format_->CreateReaderBuilder(/*batch_size=*/8));
+    ASSERT_OK_AND_ASSIGN(std::unique_ptr<FileBatchReader> reader, 
builder->Build(input));
+    auto set_selection = [&](const std::optional<RoaringBitmap32>& selection) 
-> Status {
+        ArrowSchema schema;
+        PAIMON_RETURN_NOT_OK_FROM_ARROW(
+            arrow::ExportSchema(*arrow::schema(data_type->fields()), &schema));
+        return reader->SetReadSchema(&schema, nullptr, selection);
+    };
+    ASSERT_OK(set_selection(RoaringBitmap32::From({0})));
+    input->bytes_read = 0;
+    ASSERT_OK_AND_ASSIGN(BatchReader::ReadBatch first, reader->NextBatch());
+    auto first_array = arrow::ImportArray(first.first.get(), 
first.second.get()).ValueOrDie();
+    ASSERT_TRUE(first_array->Equals(source->Slice(0, 1)));
+    ASSERT_LT(input->bytes_read, static_cast<int64_t>(bytes.size()) / 2);
+    const int64_t prefix_reads = input->bytes_read;
+    ASSERT_OK_AND_ASSIGN(BatchReader::ReadBatch eof, reader->NextBatch());
+    ASSERT_TRUE(BatchReader::IsEofBatch(eof));
+    ASSERT_EQ(input->bytes_read, prefix_reads);
+    ASSERT_NOK(reader->GetPreviousBatchFileRowId(0));
+
+    // Exhausting a selection must not be treated as the end of the file.
+    ASSERT_OK_AND_ASSIGN(uint64_t row_count, reader->GetNumberOfRows());
+    ASSERT_EQ(row_count, 256);
+    ASSERT_OK(set_selection(RoaringBitmap32::From({255})));
+    input->bytes_read = 0;
+    ASSERT_OK_AND_ASSIGN(BatchReader::ReadBatch last, reader->NextBatch());
+    auto last_array = arrow::ImportArray(last.first.get(), 
last.second.get()).ValueOrDie();
+    ASSERT_TRUE(last_array->Equals(source->Slice(255, 1)));
+    ASSERT_OK_AND_ASSIGN(uint64_t last_row, 
reader->GetPreviousBatchFileRowId(0));
+    ASSERT_EQ(last_row, 255);
+    const int64_t sequential_reads = input->bytes_read;
+
+    ASSERT_OK(set_selection(std::nullopt));
+    ASSERT_OK_AND_ASSIGN(std::shared_ptr<arrow::ChunkedArray> all,
+                         
paimon::test::ReadResultCollector::CollectResult(reader.get()));
+    ASSERT_TRUE(all->Equals(std::make_shared<arrow::ChunkedArray>(source)));
+    ASSERT_OK(set_selection(RoaringBitmap32::From({255})));
+    input->bytes_read = 0;
+    input->seek_count = 0;
+    ASSERT_OK_AND_ASSIGN(last, reader->NextBatch());
+    last_array = arrow::ImportArray(last.first.get(), 
last.second.get()).ValueOrDie();
+    ASSERT_TRUE(last_array->Equals(source->Slice(255, 1)));
+    ASSERT_OK_AND_ASSIGN(last_row, reader->GetPreviousBatchFileRowId(0));
+    ASSERT_EQ(last_row, 255);
+    ASSERT_GT(input->seek_count, 0);
+    ASSERT_LT(input->bytes_read, sequential_reads);
+}
+
+TEST_F(AvroFileBatchReaderTest, TestBitmapSelectionOnEmptyFileAndReset) {
+    const std::string path = PathUtil::JoinPath(dir_->Str(), "empty.avro");
+    auto schema = ::avro::compileJsonSchemaFromString(
+        
R"({"type":"record","name":"row","fields":[{"name":"id","type":"int"}]})");
+    ::avro::DataFileWriterBase writer(path.c_str(), schema, 
/*syncInterval=*/1024);
+    writer.close();
+    ASSERT_OK_AND_ASSIGN(std::shared_ptr<ReaderBuilder> builder,
+                         file_format_->CreateReaderBuilder(/*batch_size=*/2));
+    ASSERT_OK_AND_ASSIGN(std::shared_ptr<InputStream> input, fs_->Open(path));
+    ASSERT_OK_AND_ASSIGN(std::unique_ptr<FileBatchReader> reader, 
builder->Build(input));
+    // Reach EOF first so the reader has a complete, empty block index.
+    ASSERT_OK_AND_ASSIGN(BatchReader::ReadBatch initial, reader->NextBatch());
+    ASSERT_TRUE(BatchReader::IsEofBatch(initial));
+    for (const std::optional<RoaringBitmap32>& selection :
+         std::vector<std::optional<RoaringBitmap32>>{
+             RoaringBitmap32(), RoaringBitmap32::From({0, 99}), std::nullopt}) 
{
+        ASSERT_OK_AND_ASSIGN(std::unique_ptr<ArrowSchema> read_schema, 
reader->GetFileSchema());
+        ASSERT_OK(reader->SetReadSchema(read_schema.get(), nullptr, 
selection));
+        ASSERT_NOK(reader->GetPreviousBatchFileRowId(0));
+        for (int32_t i = 0; i < 2; ++i) {
+            ASSERT_OK_AND_ASSIGN(BatchReader::ReadBatch batch, 
reader->NextBatch());
+            ASSERT_TRUE(BatchReader::IsEofBatch(batch));
+            ASSERT_NOK(reader->GetPreviousBatchFileRowId(0));
+        }
+        ASSERT_OK_AND_ASSIGN(uint64_t row_count, reader->GetNumberOfRows());
+        ASSERT_EQ(row_count, 0);
+    }
+}
+
+TEST_F(AvroFileBatchReaderTest, TestBlockIndexReset) {
+    AvroFileBatchReader::BlockIndex index;
+    index.Observe(/*row=*/0, /*file_offset=*/100);
+    index.Reset();
+    // A partial index must be discarded before rebuilding from the first row.
+    index.Observe(/*row=*/0, /*file_offset=*/200);
+    index.Finish(/*row_count=*/2);
+    ASSERT_EQ(index.RowCount(), 2);
+    ASSERT_TRUE(index.Locate(1));
+    ASSERT_EQ(index.Locate(1)->file_offset, 200);
+
+    // A completed index remains usable after a projection or selection reset.
+    index.Reset();
+    ASSERT_EQ(index.RowCount(), 2);
+    ASSERT_TRUE(index.Locate(1));
+    ASSERT_EQ(index.Locate(1)->file_offset, 200);
+
+    AvroFileBatchReader::BlockIndex oversized;
+    for (uint64_t row = 0; row <= 64 * 1024; ++row) {
+        oversized.Observe(row, static_cast<int64_t>(row));
+    }
+    oversized.Finish(/*row_count=*/64 * 1024 + 1);
+    ASSERT_FALSE(oversized.RowCount());
+    for (int32_t reset = 0; reset < 2; ++reset) {
+        oversized.Reset();
+        // An immutable file already known to exceed the limit must not start 
a new index.
+        oversized.Observe(/*row=*/0, /*file_offset=*/100);
+        oversized.Finish(/*row_count=*/1);
+        ASSERT_FALSE(oversized.RowCount());
+        ASSERT_FALSE(oversized.Locate(0));
+    }
+}
+
+TEST_F(AvroFileBatchReaderTest, TestBitmapSelectionBeyondBlockIndexLimit) {
+    const std::string path = PathUtil::JoinPath(dir_->Str(), 
"many-blocks.avro");
+    auto schema = ::avro::compileJsonSchemaFromString(
+        
R"({"type":"record","name":"row","fields":[{"name":"id","type":"int"}]})");
+    // One row per block exceeds the bounded index without requiring a large 
payload.
+    constexpr int32_t kRowCount = 64 * 1024 + 2;
+    ::avro::DataFileWriterBase writer(path.c_str(), schema, 
/*syncInterval=*/1024);
+    for (int32_t i = 0; i < kRowCount; ++i) {
+        writer.encoder().encodeInt(i);
+        writer.incr();
+        writer.flush();
+    }
+    writer.close();
+    ASSERT_OK_AND_ASSIGN(std::shared_ptr<ReaderBuilder> builder,
+                         
file_format_->CreateReaderBuilder(/*batch_size=*/1024));
+    ASSERT_OK_AND_ASSIGN(std::shared_ptr<InputStream> input, fs_->Open(path));
+    ASSERT_OK_AND_ASSIGN(std::unique_ptr<FileBatchReader> reader, 
builder->Build(input));
+    int64_t scanned_rows = 0;
+    while (true) {
+        ASSERT_OK_AND_ASSIGN(BatchReader::ReadBatch batch, 
reader->NextBatch());
+        if (BatchReader::IsEofBatch(batch)) {
+            break;
+        }
+        auto array = arrow::ImportArray(batch.first.get(), 
batch.second.get()).ValueOrDie();
+        scanned_rows += array->length();
+    }
+    ASSERT_EQ(scanned_rows, kRowCount);
+    // Both sides of the index limit must remain readable after falling back 
to sequential skips.
+    const std::vector<int32_t> ids = {0, 64 * 1024 - 1, 64 * 1024, kRowCount - 
1};
+    ASSERT_OK_AND_ASSIGN(std::unique_ptr<ArrowSchema> read_schema, 
reader->GetFileSchema());
+    ASSERT_OK(reader->SetReadSchema(read_schema.get(), nullptr, 
RoaringBitmap32::From(ids)));
+    ASSERT_OK_AND_ASSIGN(BatchReader::ReadBatch selected, reader->NextBatch());
+    ASSERT_FALSE(BatchReader::IsEofBatch(selected));
+    auto array = arrow::ImportArray(selected.first.get(), 
selected.second.get()).ValueOrDie();
+    auto rows = checked_pointer_cast<arrow::StructArray>(array);
+    auto values = checked_pointer_cast<arrow::Int32Array>(rows->field(0));
+    ASSERT_EQ(values->length(), ids.size());
+    for (size_t i = 0; i < ids.size(); ++i) {
+        ASSERT_EQ(values->Value(i), ids[i]);
+        ASSERT_OK_AND_ASSIGN(uint64_t file_row, 
reader->GetPreviousBatchFileRowId(i));
+        ASSERT_EQ(file_row, ids[i]);
+    }
+    ASSERT_OK_AND_ASSIGN(BatchReader::ReadBatch eof, reader->NextBatch());
+    ASSERT_TRUE(BatchReader::IsEofBatch(eof));
+    ASSERT_NOK(reader->GetPreviousBatchFileRowId(0));
+    ASSERT_OK_AND_ASSIGN(read_schema, reader->GetFileSchema());
+    ASSERT_OK(reader->SetReadSchema(read_schema.get(), nullptr, std::nullopt));
+    ASSERT_OK_AND_ASSIGN(BatchReader::ReadBatch restored, reader->NextBatch());
+    ASSERT_FALSE(BatchReader::IsEofBatch(restored));
+    ASSERT_EQ(restored.first->length, 1024);
+    ASSERT_OK_AND_ASSIGN(uint64_t first_row, 
reader->GetPreviousBatchFileRowId(0));
+    ASSERT_EQ(first_row, 0);
+    ASSERT_TRUE(arrow::ImportArray(restored.first.get(), 
restored.second.get()).ok());
+}
+
 TEST_F(AvroFileBatchReaderTest, TestGetPreviousBatchFileRowId) {
     std::string path = paimon::test::GetDataDir() +
                        "/avro/append_simple.db/"
diff --git a/test/inte/scan_and_read_inte_test.cpp 
b/test/inte/scan_and_read_inte_test.cpp
index 093d1385..5e5672e9 100644
--- a/test/inte/scan_and_read_inte_test.cpp
+++ b/test/inte/scan_and_read_inte_test.cpp
@@ -23,6 +23,7 @@
 #include <map>
 #include <memory>
 #include <optional>
+#include <set>
 #include <string>
 #include <tuple>
 #include <utility>
@@ -48,6 +49,7 @@
 #include "paimon/core/table/source/deletion_file.h"
 #include "paimon/core/table/source/key_value_table_read.h"
 #include "paimon/defs.h"
+#include "paimon/executor.h"
 #include "paimon/fs/file_system.h"
 #include "paimon/fs/local/local_file_system.h"
 #include "paimon/memory/memory_pool.h"
@@ -64,6 +66,7 @@
 #include "paimon/table/source/startup_mode.h"
 #include "paimon/table/source/table_read.h"
 #include "paimon/table/source/table_scan.h"
+#include "paimon/testing/utils/counting_cache_test_utils.h"
 #include "paimon/testing/utils/io_exception_helper.h"
 #include "paimon/testing/utils/read_result_collector.h"
 #include "paimon/testing/utils/test_helper.h"
@@ -426,6 +429,302 @@ TEST_P(ScanAndReadInteTest, TestWithAppendSnapshot3) {
     ASSERT_TRUE(expected->Equals(read_result)) << read_result->ToString();
 }
 
+TEST(SelectiveManifestDecodeInteTest, TestPartitionedPointLookup) {
+    // Scale the many-partition/many-bucket point-lookup workload down for CI.
+    constexpr int32_t kPartitions = 4;
+    constexpr int32_t kBuckets = 32;
+    constexpr int32_t kCandidateKeys = 1024;
+    const std::string payload(16 * 1024, 'x');
+    auto dir = UniqueTestDirectory::Create("local");
+    ASSERT_TRUE(dir);
+    arrow::FieldVector fields = {arrow::field("p", arrow::int32()),
+                                 arrow::field("rowkey", arrow::utf8()),
+                                 arrow::field("payload", arrow::utf8())};
+    auto schema = arrow::schema(fields);
+    auto data_type = arrow::struct_(fields);
+    std::map<std::string, std::string> options = {{Options::FILE_FORMAT, 
"orc"},
+                                                  {Options::BUCKET, 
fmt::format("{}", kBuckets)},
+                                                  {Options::BUCKET_KEY, 
"rowkey"},
+                                                  
{Options::MANIFEST_COMPRESSION, "zstd"},
+                                                  
{Options::MANIFEST_TARGET_FILE_SIZE, "64 mb"}};
+    ASSERT_OK_AND_ASSIGN(std::unique_ptr<TestHelper> helper,
+                         TestHelper::Create(dir->Str(), schema, {"p"}, {}, 
options, false));
+    const std::string table_path = PathUtil::JoinPath(dir->Str(), 
"foo.db/bar");
+
+    std::vector<std::string> candidate_rows;
+    for (int32_t i = 0; i < kCandidateKeys; ++i) {
+        candidate_rows.push_back(fmt::format(R"(["key{:04}"])", i));
+    }
+    auto key_array =
+        arrow::ipc::internal::json::ArrayFromJSON(
+            arrow::struct_({fields[1]}), fmt::format("[{}]", 
fmt::join(candidate_rows, ",")))
+            .ValueOrDie();
+    ArrowArray c_keys;
+    ArrowSchema c_key_schema;
+    ASSERT_TRUE(arrow::ExportArray(*key_array, &c_keys, &c_key_schema).ok());
+    ASSERT_OK_AND_ASSIGN(std::unique_ptr<BucketIdCalculator> calculator,
+                         BucketIdCalculator::Create(false, kBuckets, 
GetDefaultPool()));
+    std::vector<int32_t> bucket_ids(kCandidateKeys);
+    ASSERT_OK(calculator->CalculateBucketIds(&c_keys, &c_key_schema, 
bucket_ids.data()));
+    std::vector<std::string> keys(kBuckets);
+    for (int32_t i = 0; i < kCandidateKeys; ++i) {
+        if (keys[bucket_ids[i]].empty()) {
+            keys[bucket_ids[i]] = fmt::format("key{:04}", i);
+        }
+    }
+    std::vector<std::unique_ptr<RecordBatch>> batches;
+    for (int32_t p = 0; p < kPartitions; ++p) {
+        for (int32_t bucket = 0; bucket < kBuckets; ++bucket) {
+            ASSERT_FALSE(keys[bucket].empty());
+            ASSERT_OK_AND_ASSIGN(
+                std::unique_ptr<RecordBatch> batch,
+                TestHelper::MakeRecordBatch(
+                    data_type, fmt::format(R"([[{},"{}","{}"]])", p, 
keys[bucket], payload),
+                    {{"p", fmt::format("{}", p)}}, bucket, {}));
+            batches.push_back(std::move(batch));
+        }
+    }
+    ASSERT_OK(helper->WriteAndCommit(std::move(batches), 0, std::nullopt));
+    const std::string& lookup_key = keys.back();
+    auto predicate =
+        PredicateBuilder::Equal(1, "rowkey", FieldType::STRING,
+                                Literal(FieldType::STRING, lookup_key.data(), 
lookup_key.size()));
+    ASSERT_OK_AND_ASSIGN(std::shared_ptr<Executor> executor, 
CreateDefaultExecutor(1));
+    // Plans and cache values can outlive a scan and reference its allocator.
+    // Keep every measured pool alive until all plans and caches have been 
destroyed.
+    std::vector<std::shared_ptr<MemoryPool>> scan_pools;
+
+    struct ScanResult {
+        std::shared_ptr<Plan> plan;
+        uint64_t cache_enabled;
+        uint64_t cache_hit;
+        int64_t peak_bytes;
+    };
+    auto scan = [&](bool lazy_decode, const std::shared_ptr<Cache>& cache,
+                    bool entry_cache) -> Result<ScanResult> {
+        std::shared_ptr<MemoryPool> pool = GetMemoryPool();
+        scan_pools.push_back(pool);
+        ScanContextBuilder builder(table_path);
+        builder.WithMemoryPool(pool)
+            .WithExecutor(executor)
+            .WithCache(cache)
+            .AddOption(Options::SCAN_MANIFEST_ENTRY_CACHE_MAX_SNAPSHOTS, 
entry_cache ? "3" : "0")
+            .AddOption(Options::SCAN_MANIFEST_ENTRY_LAZY_DECODE_ENABLED,
+                       lazy_decode ? "true" : "false")
+            .AddOption(Options::READ_BATCH_SIZE, "128");
+        if (entry_cache) {
+            // No explicit bucket or partition filter: the predicate must 
select the bucket.
+            builder.SetPredicate(predicate);
+        }
+        PAIMON_ASSIGN_OR_RAISE(std::unique_ptr<ScanContext> context, 
builder.Finish());
+        PAIMON_ASSIGN_OR_RAISE(std::unique_ptr<TableScan> table_scan,
+                               TableScan::Create(std::move(context)));
+        PAIMON_ASSIGN_OR_RAISE(std::shared_ptr<Plan> plan, 
table_scan->CreatePlan());
+        PAIMON_ASSIGN_OR_RAISE(uint64_t enabled, 
table_scan->GetMetrics()->GetCounter(
+                                                     
ScanMetrics::LAST_SNAPSHOT_CACHE_ENABLED));
+        PAIMON_ASSIGN_OR_RAISE(uint64_t hit, 
table_scan->GetMetrics()->GetCounter(
+                                                 
ScanMetrics::LAST_SNAPSHOT_CACHE_HIT));
+        return ScanResult{plan, enabled, hit, pool->MaxMemoryUsage()};
+    };
+    auto check_read = [&](const ScanResult& result, int32_t partition_count, 
bool historical) {
+        ASSERT_EQ(result.cache_enabled, 1);
+        ASSERT_EQ(result.plan->Splits().size(), partition_count);
+        for (const auto& split : result.plan->Splits()) {
+            auto data_split = std::dynamic_pointer_cast<DataSplit>(split);
+            ASSERT_TRUE(data_split);
+            ASSERT_TRUE(data_split->Bucket() == kBuckets - 1 ||
+                        (historical && data_split->Bucket() == kBuckets / 2 - 
1));
+            ASSERT_EQ(data_split->GetFileList().size(), 1);
+        }
+        ReadContextBuilder builder(table_path);
+        builder.SetPredicate(predicate).EnablePredicateFilter(true);
+        ASSERT_OK_AND_ASSIGN(std::unique_ptr<ReadContext> context, 
builder.Finish());
+        ASSERT_OK_AND_ASSIGN(std::unique_ptr<TableRead> read,
+                             TableRead::Create(std::move(context)));
+        ASSERT_OK_AND_ASSIGN(std::unique_ptr<BatchReader> reader,
+                             read->CreateReader(result.plan->Splits()));
+        ASSERT_OK_AND_ASSIGN(std::shared_ptr<arrow::ChunkedArray> rows,
+                             
ReadResultCollector::CollectResult(std::move(reader)));
+        ASSERT_TRUE(rows);
+        ASSERT_EQ(rows->length(), partition_count);
+        std::set<int32_t> partitions;
+        for (const auto& chunk : rows->chunks()) {
+            auto values = std::dynamic_pointer_cast<arrow::StructArray>(chunk);
+            ASSERT_TRUE(values);
+            auto p = 
std::dynamic_pointer_cast<arrow::Int32Array>(values->GetFieldByName("p"));
+            auto key =
+                
std::dynamic_pointer_cast<arrow::StringArray>(values->GetFieldByName("rowkey"));
+            auto data =
+                
std::dynamic_pointer_cast<arrow::StringArray>(values->GetFieldByName("payload"));
+            ASSERT_TRUE(p && key && data);
+            for (int64_t i = 0; i < values->length(); ++i) {
+                ASSERT_TRUE(partitions.insert(p->Value(i)).second);
+                ASSERT_EQ(key->GetString(i), lookup_key);
+                ASSERT_EQ(data->GetString(i), payload);
+            }
+        }
+        for (int32_t p = 0; p < partition_count; ++p) {
+            ASSERT_EQ(partitions.count(p), 1);
+        }
+    };
+
+    std::vector<std::shared_ptr<CountingRoutingCache>> caches;
+    std::vector<int64_t> peaks;
+    for (bool lazy_decode : {false, true}) {
+        auto cache = std::make_shared<CountingRoutingCache>(
+            std::map<CacheKind, int64_t>{{CacheKind::MANIFEST, 64 * 1024 * 
1024},
+                                         {CacheKind::SNAPSHOT_LIVE_MANIFEST, 
64 * 1024 * 1024}});
+        caches.push_back(cache);
+        // Warm only raw manifest bytes, with a separate pool, before 
measuring either mode.
+        ASSERT_OK_AND_ASSIGN(ScanResult warm, scan(false, cache, false));
+        ASSERT_EQ(warm.cache_enabled, 0);
+        size_t file_count = 0;
+        for (const auto& split : warm.plan->Splits()) {
+            auto data_split = std::dynamic_pointer_cast<DataSplit>(split);
+            ASSERT_TRUE(data_split);
+            file_count += data_split->GetFileList().size();
+        }
+        ASSERT_EQ(file_count, kPartitions * kBuckets);
+        const int64_t supplier_calls = 
cache->SupplierCallCount(CacheKind::MANIFEST);
+        ASSERT_GT(supplier_calls, 0);
+        ASSERT_OK_AND_ASSIGN(ScanResult measured, scan(lazy_decode, cache, 
true));
+        ASSERT_EQ(measured.cache_hit, 0);
+        ASSERT_NO_FATAL_FAILURE(check_read(measured, kPartitions, false));
+        ASSERT_EQ(cache->SupplierCallCount(CacheKind::MANIFEST), 
supplier_calls);
+        peaks.push_back(measured.peak_bytes);
+        RecordProperty(lazy_decode ? "selective_peak_bytes" : 
"baseline_peak_bytes",
+                       fmt::format("{}", measured.peak_bytes));
+        ASSERT_OK_AND_ASSIGN(ScanResult hit, scan(lazy_decode, cache, true));
+        ASSERT_EQ(hit.cache_hit, 1);
+        ASSERT_NO_FATAL_FAILURE(check_read(hit, kPartitions, false));
+        // Manifest lists are still consulted, but their bytes must also come 
from the cache.
+        ASSERT_EQ(cache->SupplierCallCount(CacheKind::MANIFEST), 
supplier_calls);
+    }
+    // Selective decoding also applies when only manifest entries, not raw 
bytes, are cached.
+    auto entry_cache_only =
+        
std::make_shared<CountingRoutingCache>(CacheKind::SNAPSHOT_LIVE_MANIFEST, 64 * 
1024 * 1024);
+    ASSERT_OK_AND_ASSIGN(ScanResult uncached_manifest, scan(true, 
entry_cache_only, true));
+    ASSERT_EQ(uncached_manifest.cache_hit, 0);
+    ASSERT_NO_FATAL_FAILURE(check_read(uncached_manifest, kPartitions, false));
+    ASSERT_GT(entry_cache_only->GetCount(CacheKind::MANIFEST), 0);
+    ASSERT_EQ(entry_cache_only->SupplierCallCount(CacheKind::MANIFEST), 0);
+    RecordProperty("uncached_manifest_peak_bytes", fmt::format("{}", 
uncached_manifest.peak_bytes));
+    // Wide, real column statistics make full Arrow materialization observable 
without timing gates.
+    ASSERT_LT(peaks[1], peaks[0] / 2);
+    ASSERT_LT(uncached_manifest.peak_bytes, peaks[0] / 2);
+
+    // Change both schema ID and bucket count, then append the same key in a 
new partition.
+    // Old files must still be read from bucket 31 while the new file belongs 
to bucket 15.
+    helper.reset();
+    options[Options::BUCKET] = fmt::format("{}", kBuckets / 2);
+    ASSERT_OK(TestHelper::WriteNextSchema(
+        dir->GetFileSystem(), table_path,
+        {DataField(0, fields[0]), DataField(1, fields[1]), DataField(2, 
fields[2])}, 2, options));
+    ASSERT_OK_AND_ASSIGN(helper, TestHelper::Create(table_path, options, 
false));
+    ASSERT_OK_AND_ASSIGN(
+        std::unique_ptr<RecordBatch> next_batch,
+        TestHelper::MakeRecordBatch(
+            data_type, fmt::format(R"([[{},"{}","{}"]])", kPartitions, 
lookup_key, payload),
+            {{"p", fmt::format("{}", kPartitions)}}, kBuckets / 2 - 1, {}));
+    ASSERT_OK(helper->WriteAndCommit(std::move(next_batch), 1, std::nullopt));
+    for (size_t i = 0; i < caches.size(); ++i) {
+        ASSERT_OK_AND_ASSIGN(ScanResult next, scan(i != 0, caches[i], true));
+        ASSERT_EQ(next.cache_hit, 0);
+        ASSERT_EQ(next.plan->SnapshotId(), 2);
+        ASSERT_NO_FATAL_FAILURE(check_read(next, kPartitions + 1, true));
+        ASSERT_OK_AND_ASSIGN(ScanResult hit, scan(i != 0, caches[i], true));
+        ASSERT_EQ(hit.cache_hit, 1);
+        ASSERT_NO_FATAL_FAILURE(check_read(hit, kPartitions + 1, true));
+    }
+}
+
+TEST(SelectiveManifestDecodeInteTest, 
TestSchemaEvolutionPreservesBucketPruning) {
+    constexpr int32_t kBuckets = 4;
+    auto key_field = arrow::field("rowkey", arrow::int32());
+    auto added_field = arrow::field("value", arrow::int32());
+    constexpr int32_t kKey = -1;
+    auto array = 
arrow::ipc::internal::json::ArrayFromJSON(arrow::struct_({key_field}),
+                                                           
fmt::format("[[{}]]", kKey))
+                     .ValueOrDie();
+    ArrowArray c_array;
+    ArrowSchema c_schema;
+    ASSERT_TRUE(arrow::ExportArray(*array, &c_array, &c_schema).ok());
+    ASSERT_OK_AND_ASSIGN(std::unique_ptr<BucketIdCalculator> calculator,
+                         BucketIdCalculator::Create(false, kBuckets, 
GetDefaultPool()));
+    int32_t bucket = 0;
+    ASSERT_OK(calculator->CalculateBucketIds(&c_array, &c_schema, &bucket));
+    auto dir = UniqueTestDirectory::Create("local");
+    ASSERT_TRUE(dir);
+    std::map<std::string, std::string> options = {{Options::FILE_FORMAT, 
"orc"},
+                                                  {Options::BUCKET, 
fmt::format("{}", kBuckets)},
+                                                  {Options::BUCKET_KEY, 
"rowkey"}};
+    ASSERT_OK_AND_ASSIGN(
+        std::unique_ptr<TestHelper> helper,
+        TestHelper::Create(dir->Str(), arrow::schema({key_field}), {}, {}, 
options, false));
+    const std::string table_path = PathUtil::JoinPath(dir->Str(), 
"foo.db/bar");
+    ASSERT_OK_AND_ASSIGN(std::unique_ptr<RecordBatch> batch,
+                         
TestHelper::MakeRecordBatch(arrow::struct_({key_field}),
+                                                     fmt::format("[[{}]]", 
kKey), {}, bucket, {}));
+    std::vector<std::unique_ptr<RecordBatch>> batches;
+    batches.push_back(std::move(batch));
+    ASSERT_OK(helper->WriteAndCommit(std::move(batches), 0, std::nullopt));
+    helper.reset();
+    ASSERT_OK(TestHelper::WriteNextSchema(dir->GetFileSystem(), table_path,
+                                          {DataField(0, key_field), 
DataField(1, added_field)}, 1,
+                                          options));
+    auto predicate = PredicateBuilder::Equal(0, "rowkey", FieldType::INT, 
Literal(kKey));
+    auto expected =
+        arrow::ipc::internal::json::ArrayFromJSON(
+            arrow::struct_({arrow::field("_VALUE_KIND", arrow::int8()), 
key_field, added_field}),
+            fmt::format("[[0, {}, null]]", kKey))
+            .ValueOrDie();
+    for (bool cache_manifest_bytes : {false, true}) {
+        SCOPED_TRACE(cache_manifest_bytes);
+        for (bool lazy_decode : {false, true}) {
+            SCOPED_TRACE(lazy_decode);
+            std::map<CacheKind, int64_t> capacities = {
+                {CacheKind::SNAPSHOT_LIVE_MANIFEST, 16 * 1024 * 1024}};
+            if (cache_manifest_bytes) {
+                capacities.emplace(CacheKind::MANIFEST, 16 * 1024 * 1024);
+            }
+            auto cache = std::make_shared<CountingRoutingCache>(capacities);
+            for (int32_t attempt = 0; attempt < 2; ++attempt) {
+                ScanContextBuilder builder(table_path);
+                builder.SetPredicate(predicate)
+                    .WithCache(cache)
+                    
.AddOption(Options::SCAN_MANIFEST_ENTRY_CACHE_MAX_SNAPSHOTS, "3")
+                    
.AddOption(Options::SCAN_MANIFEST_ENTRY_LAZY_DECODE_ENABLED,
+                               lazy_decode ? "true" : "false");
+                ASSERT_OK_AND_ASSIGN(std::unique_ptr<ScanContext> context, 
builder.Finish());
+                ASSERT_OK_AND_ASSIGN(std::unique_ptr<TableScan> scan,
+                                     TableScan::Create(std::move(context)));
+                ASSERT_OK_AND_ASSIGN(std::shared_ptr<Plan> plan, 
scan->CreatePlan());
+                ASSERT_EQ(plan->Splits().size(), 1);
+                auto split = 
std::dynamic_pointer_cast<DataSplit>(plan->Splits()[0]);
+                ASSERT_TRUE(split);
+                ASSERT_EQ(split->Bucket(), bucket);
+                ASSERT_OK_AND_ASSIGN(uint64_t hit, 
scan->GetMetrics()->GetCounter(
+                                                       
ScanMetrics::LAST_SNAPSHOT_CACHE_HIT));
+                ASSERT_EQ(hit, attempt);
+                ReadContextBuilder read_builder(table_path);
+                
read_builder.SetPredicate(predicate).EnablePredicateFilter(true);
+                ASSERT_OK_AND_ASSIGN(std::unique_ptr<ReadContext> read_context,
+                                     read_builder.Finish());
+                ASSERT_OK_AND_ASSIGN(std::unique_ptr<TableRead> read,
+                                     
TableRead::Create(std::move(read_context)));
+                ASSERT_OK_AND_ASSIGN(std::unique_ptr<BatchReader> reader,
+                                     read->CreateReader(plan->Splits()));
+                ASSERT_OK_AND_ASSIGN(std::shared_ptr<arrow::ChunkedArray> rows,
+                                     
ReadResultCollector::CollectResult(std::move(reader)));
+                ASSERT_TRUE(rows);
+                
ASSERT_TRUE(rows->Equals(std::make_shared<arrow::ChunkedArray>(expected)))
+                    << rows->type()->ToString() << "\n"
+                    << rows->ToString();
+            }
+        }
+    }
+}
+
 TEST_P(ScanAndReadInteTest, TestWithAppendBucketKeyPointLookup) {
     for (const auto& key_type : {arrow::utf8(), arrow::binary()}) {
         SCOPED_TRACE(key_type->ToString());

Reply via email to