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 d46b25c1 fix(scan): share bucket pruning across append and key-value 
scans (#284)
d46b25c1 is described below

commit d46b25c1ed28bb776685b33468a1054d58f986f2
Author: wangyong9999 <[email protected]>
AuthorDate: Wed Sep 9 11:47:28 2026 +0800

    fix(scan): share bucket pruning across append and key-value scans (#284)
---
 docs/source/api/scan.rst                           |  26 +++
 include/paimon/cache/cache.h                       |   3 -
 src/paimon/common/io/cache/cache_key.cpp           |  58 ++----
 src/paimon/common/io/cache/cache_key.h             |  65 ++++++
 src/paimon/common/io/cache/lru_cache_test.cpp      |  34 +++-
 src/paimon/core/bucket/bucket_select_converter.cpp |  45 ++++-
 src/paimon/core/bucket/bucket_select_converter.h   |  27 +++
 .../core/bucket/bucket_select_converter_test.cpp   |  74 +++++++
 .../core/operation/append_only_file_store_scan.cpp |   2 +
 .../core/operation/append_only_file_store_scan.h   |   1 +
 .../operation/append_only_file_store_scan_test.cpp | 163 ++++++++++++++++
 src/paimon/core/operation/file_store_scan.cpp      |  82 ++++++--
 src/paimon/core/operation/file_store_scan.h        |  15 +-
 .../core/operation/key_value_file_store_scan.cpp   |  21 --
 .../operation/key_value_file_store_scan_test.cpp   |  47 +++++
 src/paimon/core/schema/schema_manager.cpp          |   8 +-
 src/paimon/core/schema/schema_manager.h            |   3 +-
 src/paimon/core/schema/schema_manager_test.cpp     |  19 +-
 test/inte/scan_and_read_inte_test.cpp              | 217 ++++++++++++++++++---
 19 files changed, 768 insertions(+), 142 deletions(-)

diff --git a/docs/source/api/scan.rst b/docs/source/api/scan.rst
index 142a5168..0194aa6d 100644
--- a/docs/source/api/scan.rst
+++ b/docs/source/api/scan.rst
@@ -21,6 +21,32 @@ Scan
 
 .. _cpp-api-scan:
 
+Bucket pruning
+==============
+
+For fixed-bucket append and primary-key tables, an equality predicate on every 
bucket key lets
+the scan derive the target bucket using the table's bucket function. Other 
buckets
+are excluded from the scan plan without requiring an explicit bucket ID from 
the
+caller. An explicit bucket filter takes precedence. Queries that do not 
constrain
+all bucket keys with equality, and bucket-unaware tables, keep the existing 
scan
+behavior. Both scan types use a shared selector that computes the bucket with
+each manifest entry's total bucket count, so rescaled files are not filtered 
using
+the current table's bucket count. Historical schemas remain eligible when 
their ordered bucket-key field IDs,
+types and bucket function match. Changes to unrelated columns do not disable
+pruning. Incompatible bucket schemas and nonpositive total bucket counts retain
+the existing filtering behavior.
+
+This inference prunes data files at the manifest-entry level. Manifest 
min/max-bucket
+skipping requires an explicit bucket filter. When the snapshot 
live-manifest-entry
+cache is enabled, inferred scans cache candidates by bucket, current bucket 
count
+and current schema ID. Files with other bucket counts or schema IDs remain in 
the
+cached candidates and are filtered after lookup. This permits cache reuse 
without
+discarding files that require a different bucket calculation or schema 
fallback.
+
+Decimal literals are rescaled to the bucket field's type only when the 
conversion
+is exact. NaN literals and decimals that cannot be represented exactly disable
+inferred bucket pruning.
+
 Interface
 =========
 
diff --git a/include/paimon/cache/cache.h b/include/paimon/cache/cache.h
index 5bff2a12..b282da74 100644
--- a/include/paimon/cache/cache.h
+++ b/include/paimon/cache/cache.h
@@ -44,9 +44,6 @@ class PAIMON_EXPORT CacheKey {
                                                  int32_t length, bool 
is_index);
     static std::shared_ptr<CacheKey> ForKind(const std::string& file_path, 
int64_t position,
                                              int32_t length, CacheKind kind);
-    static std::shared_ptr<CacheKey> ForSnapshotLiveManifestEntries(const 
std::string& table_path,
-                                                                    const 
std::string& branch,
-                                                                    int32_t 
bucket);
 
  public:
     virtual ~CacheKey() = default;
diff --git a/src/paimon/common/io/cache/cache_key.cpp 
b/src/paimon/common/io/cache/cache_key.cpp
index c84c8f90..998d387f 100644
--- a/src/paimon/common/io/cache/cache_key.cpp
+++ b/src/paimon/common/io/cache/cache_key.cpp
@@ -19,49 +19,6 @@
 #include "paimon/common/io/cache/cache_key.h"
 
 namespace paimon {
-namespace {
-
-class SnapshotLiveManifestEntriesCacheKey : public CacheKey {
- public:
-    SnapshotLiveManifestEntriesCacheKey(const std::string& table_path, const 
std::string& branch,
-                                        int32_t bucket)
-        : CacheKey(CacheKind::SNAPSHOT_LIVE_MANIFEST),
-          table_path_(table_path),
-          branch_(branch),
-          bucket_(bucket) {}
-
-    bool IsIndex() const override {
-        return false;
-    }
-
-    bool Equals(const CacheKey& other) const override {
-        const auto* rhs = dynamic_cast<const 
SnapshotLiveManifestEntriesCacheKey*>(&other);
-        if (!rhs) {
-            return false;
-        }
-        return table_path_ == rhs->table_path_ && branch_ == rhs->branch_ &&
-               bucket_ == rhs->bucket_ && GetKind() == rhs->GetKind();
-    }
-
-    size_t HashCode() const override {
-        size_t seed = 0;
-        seed ^= std::hash<std::string>{}(table_path_) + HASH_CONSTANT + (seed 
<< 6) + (seed >> 2);
-        seed ^= std::hash<std::string>{}(branch_) + HASH_CONSTANT + (seed << 
6) + (seed >> 2);
-        seed ^= std::hash<int32_t>{}(bucket_) + HASH_CONSTANT + (seed << 6) + 
(seed >> 2);
-        seed ^= std::hash<int32_t>{}(static_cast<int32_t>(GetKind())) + 
HASH_CONSTANT +
-                (seed << 6) + (seed >> 2);
-        return seed;
-    }
-
- private:
-    static constexpr uint64_t HASH_CONSTANT = 0x9e3779b97f4a7c15ULL;
-
-    const std::string table_path_;
-    const std::string branch_;
-    const int32_t bucket_;
-};
-
-}  // namespace
 
 std::shared_ptr<CacheKey> CacheKey::ForPosition(const std::string& file_path, 
int64_t position,
                                                 int32_t length, bool is_index) 
{
@@ -76,10 +33,17 @@ std::shared_ptr<CacheKey> CacheKey::ForKind(const 
std::string& file_path, int64_
     return key;
 }
 
-std::shared_ptr<CacheKey> CacheKey::ForSnapshotLiveManifestEntries(const 
std::string& table_path,
-                                                                   const 
std::string& branch,
-                                                                   int32_t 
bucket) {
-    return std::make_shared<SnapshotLiveManifestEntriesCacheKey>(table_path, 
branch, bucket);
+std::shared_ptr<CacheKey> SnapshotLiveManifestEntriesCacheKey::ForExplicit(
+    const std::string& table_path, const std::string& branch, int32_t bucket) {
+    return std::shared_ptr<CacheKey>(new SnapshotLiveManifestEntriesCacheKey(
+        table_path, branch, bucket, Mode::kExplicit, std::nullopt, 
std::nullopt));
+}
+
+std::shared_ptr<CacheKey> SnapshotLiveManifestEntriesCacheKey::ForInferred(
+    const std::string& table_path, const std::string& branch, int32_t bucket, 
int32_t total_buckets,
+    int64_t schema_id) {
+    return std::shared_ptr<CacheKey>(new SnapshotLiveManifestEntriesCacheKey(
+        table_path, branch, bucket, Mode::kInferred, total_buckets, 
schema_id));
 }
 
 bool PositionCacheKey::IsIndex() const {
diff --git a/src/paimon/common/io/cache/cache_key.h 
b/src/paimon/common/io/cache/cache_key.h
index 988735d1..82fa9752 100644
--- a/src/paimon/common/io/cache/cache_key.h
+++ b/src/paimon/common/io/cache/cache_key.h
@@ -20,12 +20,77 @@
 
 #include <cstdint>
 #include <memory>
+#include <optional>
 #include <string>
 
 #include "paimon/cache/cache.h"
 
 namespace paimon {
 
+class SnapshotLiveManifestEntriesCacheKey : public CacheKey {
+ public:
+    static std::shared_ptr<CacheKey> ForExplicit(const std::string& table_path,
+                                                 const std::string& branch, 
int32_t bucket);
+    static std::shared_ptr<CacheKey> ForInferred(const std::string& table_path,
+                                                 const std::string& branch, 
int32_t bucket,
+                                                 int32_t total_buckets, 
int64_t schema_id);
+
+    bool IsIndex() const override {
+        return false;
+    }
+
+    bool Equals(const CacheKey& other) const override {
+        const auto* rhs = dynamic_cast<const 
SnapshotLiveManifestEntriesCacheKey*>(&other);
+        if (!rhs) {
+            return false;
+        }
+        return table_path_ == rhs->table_path_ && branch_ == rhs->branch_ &&
+               bucket_ == rhs->bucket_ && mode_ == rhs->mode_ &&
+               total_buckets_ == rhs->total_buckets_ && schema_id_ == 
rhs->schema_id_ &&
+               GetKind() == rhs->GetKind();
+    }
+
+    size_t HashCode() const override {
+        size_t seed = 0;
+        seed ^= std::hash<std::string>{}(table_path_) + HASH_CONSTANT + (seed 
<< 6) + (seed >> 2);
+        seed ^= std::hash<std::string>{}(branch_) + HASH_CONSTANT + (seed << 
6) + (seed >> 2);
+        seed ^= std::hash<int32_t>{}(bucket_) + HASH_CONSTANT + (seed << 6) + 
(seed >> 2);
+        seed ^= std::hash<int32_t>{}(static_cast<int32_t>(GetKind())) + 
HASH_CONSTANT +
+                (seed << 6) + (seed >> 2);
+        seed ^= std::hash<std::optional<int32_t>>{}(total_buckets_) + 
HASH_CONSTANT + (seed << 6) +
+                (seed >> 2);
+        seed ^= std::hash<std::optional<int64_t>>{}(schema_id_) + 
HASH_CONSTANT + (seed << 6) +
+                (seed >> 2);
+        seed ^= std::hash<int32_t>{}(static_cast<int32_t>(mode_)) + 
HASH_CONSTANT + (seed << 6) +
+                (seed >> 2);
+        return seed;
+    }
+
+ private:
+    enum class Mode { kExplicit, kInferred };
+
+    SnapshotLiveManifestEntriesCacheKey(const std::string& table_path, const 
std::string& branch,
+                                        int32_t bucket, Mode mode,
+                                        std::optional<int32_t> total_buckets,
+                                        std::optional<int64_t> schema_id)
+        : CacheKey(CacheKind::SNAPSHOT_LIVE_MANIFEST),
+          table_path_(table_path),
+          branch_(branch),
+          bucket_(bucket),
+          mode_(mode),
+          total_buckets_(total_buckets),
+          schema_id_(schema_id) {}
+
+    static constexpr uint64_t HASH_CONSTANT = 0x9e3779b97f4a7c15ULL;
+
+    const std::string table_path_;
+    const std::string branch_;
+    const int32_t bucket_;
+    const Mode mode_;
+    const std::optional<int32_t> total_buckets_;
+    const std::optional<int64_t> schema_id_;
+};
+
 class PositionCacheKey : public CacheKey {
  public:
     PositionCacheKey(const std::string& file_path, int64_t position, int32_t 
length, bool is_index,
diff --git a/src/paimon/common/io/cache/lru_cache_test.cpp 
b/src/paimon/common/io/cache/lru_cache_test.cpp
index 1d644c70..29eb10fe 100644
--- a/src/paimon/common/io/cache/lru_cache_test.cpp
+++ b/src/paimon/common/io/cache/lru_cache_test.cpp
@@ -383,14 +383,32 @@ TEST_F(LruCacheTest, TestForKindSetsKeyKind) {
     ASSERT_EQ(CacheKind::MANIFEST, put_key->GetKind());
 }
 
-TEST_F(LruCacheTest, TestForSnapshotLiveManifestEntries) {
-    auto main_key = CacheKey::ForSnapshotLiveManifestEntries("table_path", 
"main", 0);
-    auto same_key = CacheKey::ForSnapshotLiveManifestEntries("table_path", 
"main", 0);
-    auto branch_key = CacheKey::ForSnapshotLiveManifestEntries("table_path", 
"dev", 0);
-    auto table_key = 
CacheKey::ForSnapshotLiveManifestEntries("other_table_path", "main", 0);
-    auto bucket_key = CacheKey::ForSnapshotLiveManifestEntries("table_path", 
"main", 1);
-    auto hash_in_path_key = 
CacheKey::ForSnapshotLiveManifestEntries("table#path", "main", 0);
-    auto hash_in_branch_key = 
CacheKey::ForSnapshotLiveManifestEntries("table", "path#main", 0);
+TEST_F(LruCacheTest, InferredManifestCacheKeysIncludeBucketCountAndSchema) {
+    auto key = SnapshotLiveManifestEntriesCacheKey::ForInferred("table", 
"main", 1, 4, 0);
+    auto same = SnapshotLiveManifestEntriesCacheKey::ForInferred("table", 
"main", 1, 4, 0);
+    ASSERT_TRUE(key->Equals(*same));
+    ASSERT_EQ(key->HashCode(), same->HashCode());
+    auto explicit_key = 
SnapshotLiveManifestEntriesCacheKey::ForExplicit("table", "main", 1);
+    // Schema zero is real inferred metadata, not an explicit-mode placeholder.
+    ASSERT_FALSE(key->Equals(*explicit_key));
+    ASSERT_FALSE(explicit_key->Equals(*key));
+    ASSERT_FALSE(
+        key->Equals(*SnapshotLiveManifestEntriesCacheKey::ForInferred("table", 
"main", 1, 8, 0)));
+    ASSERT_FALSE(
+        key->Equals(*SnapshotLiveManifestEntriesCacheKey::ForInferred("table", 
"main", 1, 4, 1)));
+}
+
+TEST_F(LruCacheTest, TestExplicitSnapshotLiveManifestEntriesKeys) {
+    auto main_key = 
SnapshotLiveManifestEntriesCacheKey::ForExplicit("table_path", "main", 0);
+    auto same_key = 
SnapshotLiveManifestEntriesCacheKey::ForExplicit("table_path", "main", 0);
+    auto branch_key = 
SnapshotLiveManifestEntriesCacheKey::ForExplicit("table_path", "dev", 0);
+    auto table_key =
+        SnapshotLiveManifestEntriesCacheKey::ForExplicit("other_table_path", 
"main", 0);
+    auto bucket_key = 
SnapshotLiveManifestEntriesCacheKey::ForExplicit("table_path", "main", 1);
+    auto hash_in_path_key =
+        SnapshotLiveManifestEntriesCacheKey::ForExplicit("table#path", "main", 
0);
+    auto hash_in_branch_key =
+        SnapshotLiveManifestEntriesCacheKey::ForExplicit("table", "path#main", 
0);
 
     ASSERT_EQ(CacheKind::SNAPSHOT_LIVE_MANIFEST, main_key->GetKind());
     ASSERT_TRUE(CacheKeyEqual()(main_key, same_key));
diff --git a/src/paimon/core/bucket/bucket_select_converter.cpp 
b/src/paimon/core/bucket/bucket_select_converter.cpp
index 3e8aa17e..5ae2b243 100644
--- a/src/paimon/core/bucket/bucket_select_converter.cpp
+++ b/src/paimon/core/bucket/bucket_select_converter.cpp
@@ -19,6 +19,7 @@
 #include "paimon/core/bucket/bucket_select_converter.h"
 
 #include <cassert>
+#include <cmath>
 #include <set>
 #include <string>
 #include <utility>
@@ -34,6 +35,7 @@
 #include "paimon/core/bucket/default_bucket_function.h"
 #include "paimon/core/bucket/hive_bucket_function.h"
 #include "paimon/core/bucket/mod_bucket_function.h"
+#include "paimon/core/casting/decimal_to_decimal_cast_executor.h"
 #include "paimon/data/timestamp.h"
 #include "paimon/memory/memory_pool.h"
 #include "paimon/predicate/leaf_predicate.h"
@@ -46,9 +48,25 @@ Result<std::optional<int32_t>> 
BucketSelectConverter::Convert(
     const std::shared_ptr<Predicate>& predicate, const 
std::vector<std::string>& bucket_key_names,
     const std::vector<std::shared_ptr<arrow::DataType>>& 
bucket_key_arrow_types,
     BucketFunctionType bucket_function_type, int32_t num_buckets, MemoryPool* 
pool) {
+    if (num_buckets <= 0) {
+        return std::optional<int32_t>();
+    }
+    PAIMON_ASSIGN_OR_RAISE(std::unique_ptr<BucketSelector> selector,
+                           ConvertToSelector(predicate, bucket_key_names, 
bucket_key_arrow_types,
+                                             bucket_function_type, pool));
+    if (!selector) {
+        return std::optional<int32_t>();
+    }
+    return std::optional<int32_t>(selector->Bucket(num_buckets));
+}
+
+Result<std::unique_ptr<BucketSelector>> 
BucketSelectConverter::ConvertToSelector(
+    const std::shared_ptr<Predicate>& predicate, const 
std::vector<std::string>& bucket_key_names,
+    const std::vector<std::shared_ptr<arrow::DataType>>& 
bucket_key_arrow_types,
+    BucketFunctionType bucket_function_type, MemoryPool* pool) {
     assert(pool);
-    if (!predicate || bucket_key_names.empty() || num_buckets <= 0) {
-        return std::optional<int32_t>(std::nullopt);
+    if (!predicate || bucket_key_names.empty()) {
+        return std::unique_ptr<BucketSelector>();
     }
 
     if (bucket_key_names.size() != bucket_key_arrow_types.size()) {
@@ -66,7 +84,7 @@ Result<std::optional<int32_t>> BucketSelectConverter::Convert(
 
     auto literals_opt = ExtractEqualLiterals(predicate, bucket_key_names);
     if (!literals_opt.has_value()) {
-        return std::optional<int32_t>(std::nullopt);
+        return std::unique_ptr<BucketSelector>();
     }
 
     const auto& literals_map = literals_opt.value();
@@ -79,18 +97,31 @@ Result<std::optional<int32_t>> 
BucketSelectConverter::Convert(
 
     for (int32_t i = 0; i < num_fields; i++) {
         const auto& field_name = bucket_key_names[i];
-        const auto& literal = literals_map.at(field_name);
+        Literal literal = literals_map.at(field_name);
+        // Equal NaNs can have different stored bit patterns and therefore 
different buckets.
+        if ((bucket_key_types[i] == FieldType::FLOAT && 
std::isnan(literal.GetValue<float>())) ||
+            (bucket_key_types[i] == FieldType::DOUBLE && 
std::isnan(literal.GetValue<double>()))) {
+            return std::unique_ptr<BucketSelector>();
+        }
+        if (bucket_key_types[i] == FieldType::DECIMAL) {
+            PAIMON_ASSIGN_OR_RAISE(Literal scaled, 
DecimalToDecimalCastExecutor().Cast(
+                                                       literal, 
bucket_key_arrow_types[i]));
+            // Hash the field's representation only when rescaling preserves 
the exact value.
+            if (scaled.IsNull() || !(scaled.GetValue<Decimal>() == 
literal.GetValue<Decimal>())) {
+                return std::unique_ptr<BucketSelector>();
+            }
+            literal = std::move(scaled);
+        }
         PAIMON_RETURN_NOT_OK(
             WriteLiteralToRow(i, literal, bucket_key_types[i], 
bucket_key_arrow_types[i], &writer));
     }
     writer.Complete();
 
-    // Create the bucket function and compute the bucket
+    // Retain the key and function; the bucket count belongs to each manifest 
entry.
     PAIMON_ASSIGN_OR_RAISE(
         std::unique_ptr<BucketFunction> bucket_function,
         CreateBucketFunction(bucket_function_type, bucket_key_types, 
bucket_key_arrow_types));
-    int32_t bucket = bucket_function->Bucket(row, num_buckets);
-    return std::optional<int32_t>(bucket);
+    return std::make_unique<BucketSelector>(std::move(row), 
std::move(bucket_function));
 }
 
 std::optional<std::map<std::string, Literal>> 
BucketSelectConverter::ExtractEqualLiterals(
diff --git a/src/paimon/core/bucket/bucket_select_converter.h 
b/src/paimon/core/bucket/bucket_select_converter.h
index 25eb31aa..e6921c39 100644
--- a/src/paimon/core/bucket/bucket_select_converter.h
+++ b/src/paimon/core/bucket/bucket_select_converter.h
@@ -23,10 +23,13 @@
 #include <memory>
 #include <optional>
 #include <string>
+#include <utility>
 #include <vector>
 
 #include "arrow/type_fwd.h"
 #include "paimon/bucket/bucket_function_type.h"
+#include "paimon/common/data/binary_row.h"
+#include "paimon/core/bucket/bucket_function.h"
 #include "paimon/defs.h"
 #include "paimon/predicate/literal.h"
 #include "paimon/result.h"
@@ -38,6 +41,22 @@ class BucketFunction;
 class MemoryPool;
 class Predicate;
 
+/// Selects a bucket using the bucket count recorded in each manifest entry.
+class BucketSelector {
+ public:
+    BucketSelector(BinaryRow key, std::unique_ptr<BucketFunction> function)
+        : key_(std::move(key)), function_(std::move(function)) {}
+
+    /// Compute the bucket for a positive total bucket count.
+    int32_t Bucket(int32_t num_buckets) const {
+        return function_->Bucket(key_, num_buckets);
+    }
+
+ private:
+    BinaryRow key_;
+    std::unique_ptr<BucketFunction> function_;
+};
+
 /// Converts predicates on bucket key fields to a target bucket ID.
 /// When all bucket key fields have EQUAL predicates, the converter computes
 /// which bucket the data must reside in, enabling bucket pruning during scan.
@@ -62,6 +81,14 @@ class BucketSelectConverter {
         const std::vector<std::shared_ptr<arrow::DataType>>& 
bucket_key_arrow_types,
         BucketFunctionType bucket_function_type, int32_t num_buckets, 
MemoryPool* pool);
 
+    /// Build a selector once, then evaluate it with each file's bucket count.
+    /// Returns nullptr when the predicate cannot safely constrain all bucket 
keys.
+    static Result<std::unique_ptr<BucketSelector>> ConvertToSelector(
+        const std::shared_ptr<Predicate>& predicate,
+        const std::vector<std::string>& bucket_key_names,
+        const std::vector<std::shared_ptr<arrow::DataType>>& 
bucket_key_arrow_types,
+        BucketFunctionType bucket_function_type, MemoryPool* pool);
+
  private:
     /// Extract single literal per bucket key field from EQUAL predicates.
     /// Splits the predicate by AND and looks for EQUAL leaf predicates on 
bucket key fields.
diff --git a/src/paimon/core/bucket/bucket_select_converter_test.cpp 
b/src/paimon/core/bucket/bucket_select_converter_test.cpp
index 2707eccb..85a3f3e2 100644
--- a/src/paimon/core/bucket/bucket_select_converter_test.cpp
+++ b/src/paimon/core/bucket/bucket_select_converter_test.cpp
@@ -18,6 +18,7 @@
 
 #include "paimon/core/bucket/bucket_select_converter.h"
 
+#include <limits>
 #include <optional>
 #include <string>
 #include <vector>
@@ -60,6 +61,30 @@ class BucketSelectConverterTest : public ::testing::Test {
     std::shared_ptr<MemoryPool> pool_ = GetDefaultPool();
 };
 
+TEST_F(BucketSelectConverterTest, SelectorUsesEachBucketCount) {
+    auto pool = GetDefaultPool();
+    auto predicate =
+        PredicateBuilder::Equal(0, "key", FieldType::INT, 
Literal(static_cast<int32_t>(-23)));
+    BinaryRow row = 
BinaryRowGenerator::GenerateRow({static_cast<int32_t>(-23)}, pool.get());
+    ASSERT_OK_AND_ASSIGN(auto mod_function, 
ModBucketFunction::Create(FieldType::INT));
+    ASSERT_OK_AND_ASSIGN(auto hive_function,
+                         
HiveBucketFunction::Create({HiveFieldInfo(FieldType::INT)}));
+    DefaultBucketFunction default_function;
+    const std::map<BucketFunctionType, const BucketFunction*> functions = {
+        {BucketFunctionType::DEFAULT, &default_function},
+        {BucketFunctionType::MOD, mod_function.get()},
+        {BucketFunctionType::HIVE, hive_function.get()}};
+    for (const auto& [type, function] : functions) {
+        ASSERT_OK_AND_ASSIGN(auto selector,
+                             BucketSelectConverter::ConvertToSelector(
+                                 predicate, {"key"}, {arrow::int32()}, type, 
pool.get()));
+        ASSERT_TRUE(selector);
+        for (int32_t total_buckets : {2, 4, 8, 17, 2}) {
+            ASSERT_EQ(selector->Bucket(total_buckets), function->Bucket(row, 
total_buckets));
+        }
+    }
+}
+
 TEST_F(BucketSelectConverterTest, SingleStringEqualDefault) {
     std::string value = "hello_world";
     AssertDefaultBucket(FieldType::STRING, Literal(FieldType::STRING, 
value.c_str(), value.size()),
@@ -246,6 +271,55 @@ TEST_F(BucketSelectConverterTest, 
HiveBucketFunctionWithDecimal) {
     ASSERT_EQ(function->Bucket(row, num_buckets), selected_bucket.value());
 }
 
+TEST_F(BucketSelectConverterTest, RescalesDecimalLiteralsExactly) {
+    for (int32_t precision : {10, 20}) {
+        for (BucketFunctionType function_type :
+             {BucketFunctionType::DEFAULT, BucketFunctionType::HIVE}) {
+            Decimal stored = Decimal::FromUnscaledLong(120, precision, 2);
+            auto stored_predicate =
+                PredicateBuilder::Equal(0, "amount", FieldType::DECIMAL, 
Literal(stored));
+            ASSERT_OK_AND_ASSIGN(auto expected,
+                                 
BucketSelectConverter::Convert(stored_predicate, {"amount"},
+                                                                
{arrow::decimal128(precision, 2)},
+                                                                function_type, 
17, pool_.get()));
+            ASSERT_TRUE(expected.has_value());
+            for (const auto& query : {Decimal::FromUnscaledLong(12, precision, 
1),
+                                      Decimal::FromUnscaledLong(1200, 
precision, 3)}) {
+                auto predicate =
+                    PredicateBuilder::Equal(0, "amount", FieldType::DECIMAL, 
Literal(query));
+                ASSERT_OK_AND_ASSIGN(
+                    auto result, BucketSelectConverter::Convert(predicate, 
{"amount"},
+                                                                
{arrow::decimal128(precision, 2)},
+                                                                function_type, 
17, pool_.get()));
+                ASSERT_EQ(result, expected);
+            }
+        }
+    }
+}
+
+TEST_F(BucketSelectConverterTest, InexactDecimalConversionReturnsNullopt) {
+    for (const auto& value :
+         {Decimal::FromUnscaledLong(123, 10, 3), 
Decimal::FromUnscaledLong(9999999999LL, 10, 0)}) {
+        auto predicate = PredicateBuilder::Equal(0, "amount", 
FieldType::DECIMAL, Literal(value));
+        ASSERT_OK_AND_ASSIGN(auto result, BucketSelectConverter::Convert(
+                                              predicate, {"amount"}, 
{arrow::decimal128(10, 2)},
+                                              BucketFunctionType::DEFAULT, 17, 
pool_.get()));
+        ASSERT_FALSE(result.has_value());
+    }
+}
+
+TEST_F(BucketSelectConverterTest, NaNReturnsNullopt) {
+    for (const auto& value : {Literal(std::numeric_limits<float>::quiet_NaN()),
+                              
Literal(std::numeric_limits<double>::quiet_NaN())}) {
+        auto type = value.GetType() == FieldType::FLOAT ? arrow::float32() : 
arrow::float64();
+        auto predicate = PredicateBuilder::Equal(0, "key", value.GetType(), 
value);
+        ASSERT_OK_AND_ASSIGN(auto result, BucketSelectConverter::Convert(
+                                              predicate, {"key"}, {type},
+                                              BucketFunctionType::DEFAULT, 17, 
pool_.get()));
+        ASSERT_FALSE(result.has_value());
+    }
+}
+
 TEST_F(BucketSelectConverterTest, UnsupportedFieldTypeReturnsError) {
     auto predicate =
         PredicateBuilder::Equal(0, "items", FieldType::ARRAY, 
Literal(static_cast<int32_t>(42)));
diff --git a/src/paimon/core/operation/append_only_file_store_scan.cpp 
b/src/paimon/core/operation/append_only_file_store_scan.cpp
index 953bcadb..435ece57 100644
--- a/src/paimon/core/operation/append_only_file_store_scan.cpp
+++ b/src/paimon/core/operation/append_only_file_store_scan.cpp
@@ -26,6 +26,7 @@
 #include <set>
 #include <string>
 #include <utility>
+#include <vector>
 
 #include "arrow/type.h"
 #include "fmt/format.h"
@@ -42,6 +43,7 @@
 #include "paimon/core/utils/field_mapping.h"
 #include "paimon/file_index/file_index_result.h"
 #include "paimon/predicate/predicate_utils.h"
+#include "paimon/scan_context.h"
 #include "paimon/status.h"
 
 namespace paimon {
diff --git a/src/paimon/core/operation/append_only_file_store_scan.h 
b/src/paimon/core/operation/append_only_file_store_scan.h
index 65ed66da..ba4feb1f 100644
--- a/src/paimon/core/operation/append_only_file_store_scan.h
+++ b/src/paimon/core/operation/append_only_file_store_scan.h
@@ -18,6 +18,7 @@
 
 #pragma once
 
+#include <cstdint>
 #include <memory>
 #include <vector>
 
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 fd16d47f..b45a5684 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
@@ -20,15 +20,19 @@
 
 #include <algorithm>
 #include <cstdint>
+#include <map>
 #include <optional>
 #include <string>
 #include <utility>
 #include <vector>
 
+#include "arrow/type.h"
 #include "gtest/gtest.h"
 #include "paimon/common/data/binary_row.h"
 #include "paimon/common/data/binary_row_writer.h"
 #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/manifest/manifest_entry.h"
 #include "paimon/core/manifest/partition_entry.h"
 #include "paimon/core/schema/schema_manager.h"
@@ -37,6 +41,7 @@
 #include "paimon/core/table/source/abstract_table_scan.h"
 #include "paimon/core/table/source/snapshot/snapshot_reader.h"
 #include "paimon/defs.h"
+#include "paimon/executor.h"
 #include "paimon/fs/local/local_file_system.h"
 #include "paimon/memory/memory_pool.h"
 #include "paimon/metrics.h"
@@ -46,10 +51,168 @@
 #include "paimon/status.h"
 #include "paimon/table/source/scan_metrics.h"
 #include "paimon/table/source/table_scan.h"
+#include "paimon/testing/utils/binary_row_generator.h"
 #include "paimon/testing/utils/testharness.h"
 #include "paimon/testing/utils/timezone_guard.h"
 namespace paimon::test {
 
+class AppendBucketPruningTest : public testing::Test {
+ public:
+    Result<std::unique_ptr<AppendOnlyFileStoreScan>> CreateScan(
+        const std::shared_ptr<Predicate>& predicate,
+        const std::optional<int32_t>& bucket = std::nullopt) const {
+        std::vector<DataField> fields = {DataField(0, arrow::field("rowkey", 
rowkey_type_)),
+                                         DataField(1, arrow::field("value", 
arrow::int32()))};
+        if (extra_field_) {
+            fields.emplace_back(2, arrow::field("extra", arrow::int32()));
+        }
+        auto arrow_schema = DataField::ConvertDataFieldsToArrowSchema(fields);
+        PAIMON_ASSIGN_OR_RAISE(std::shared_ptr<TableSchema> schema,
+                               TableSchema::Create(schema_id_, arrow_schema, 
{}, {}, options_));
+        PAIMON_ASSIGN_OR_RAISE(CoreOptions options, 
CoreOptions::FromMap(options_));
+        auto filters = std::make_shared<ScanFilter>(
+            predicate, std::vector<std::map<std::string, std::string>>(), 
bucket);
+        return AppendOnlyFileStoreScan::Create(nullptr, schema_manager_, 
nullptr, nullptr, schema,
+                                               arrow_schema, filters, options,
+                                               CreateDefaultExecutor(), pool_);
+    }
+
+    void CheckBuckets(const std::shared_ptr<Predicate>& predicate,
+                      const std::optional<int32_t>& expected_bucket,
+                      const std::optional<int32_t>& explicit_bucket = 
std::nullopt,
+                      int32_t total_buckets = kNumBuckets) const {
+        ASSERT_OK_AND_ASSIGN(auto scan, CreateScan(predicate, 
explicit_bucket));
+        // Every file's stats match the lookup, so only bucket pruning can 
discard a file.
+        SimpleStats stats = BinaryRowGenerator::GenerateStats(
+            {std::string("a"), 0}, {std::string("z"), 100}, {0, 0}, 
pool_.get());
+        ASSERT_OK_AND_ASSIGN(
+            auto file,
+            DataFileMeta::ForAppend("data.parquet", 100, 10, stats, 0, 9, 0, 
std::nullopt,
+                                    std::nullopt, std::nullopt, std::nullopt, 
std::nullopt));
+        for (int32_t bucket = 0; bucket < total_buckets; ++bucket) {
+            ManifestEntry entry(FileKind::Add(), BinaryRow::EmptyRow(), 
bucket, total_buckets,
+                                file);
+            ASSERT_OK_AND_ASSIGN(auto stats_scan, CreateScan(predicate, 
bucket));
+            ASSERT_OK_AND_ASSIGN(bool stats_match, 
stats_scan->FilterByStats(entry));
+            ASSERT_TRUE(stats_match);
+            ASSERT_OK_AND_ASSIGN(bool keep, scan->FilterManifestEntry(entry));
+            ASSERT_EQ(keep, !expected_bucket.has_value() || bucket == 
expected_bucket.value());
+        }
+    }
+
+    std::shared_ptr<Predicate> KeyEquals() const {
+        return PredicateBuilder::Equal(0, "rowkey", FieldType::STRING,
+                                       Literal(FieldType::STRING, "key", 3));
+    }
+
+    template <typename T>
+    void CheckMatchingValue(FieldType field_type, const T& query_value, const 
T& stored_value) {
+        options_[Options::BUCKET] = "17";
+        auto predicate = PredicateBuilder::Equal(0, "rowkey", field_type, 
Literal(query_value));
+        ASSERT_OK_AND_ASSIGN(auto comparison,
+                             
Literal(query_value).CompareTo(Literal(stored_value)));
+        ASSERT_EQ(comparison, 0);
+        BinaryRow stored_key = BinaryRowGenerator::GenerateRow({stored_value}, 
pool_.get());
+        BinaryRow query_key = BinaryRowGenerator::GenerateRow({query_value}, 
pool_.get());
+        int32_t bucket = DefaultBucketFunction().Bucket(stored_key, 17);
+        ASSERT_NE(bucket, DefaultBucketFunction().Bucket(query_key, 17));
+        SimpleStats stats = BinaryRowGenerator::GenerateStats(
+            {stored_value, 0}, {stored_value, 100}, {0, 0}, pool_.get());
+        ASSERT_OK_AND_ASSIGN(
+            auto file,
+            DataFileMeta::ForAppend("data.parquet", 100, 10, stats, 0, 9, 0, 
std::nullopt,
+                                    std::nullopt, std::nullopt, std::nullopt, 
std::nullopt));
+        ManifestEntry entry(FileKind::Add(), BinaryRow::EmptyRow(), bucket, 
17, file);
+        ASSERT_OK_AND_ASSIGN(auto stats_scan, CreateScan(predicate, bucket));
+        ASSERT_OK_AND_ASSIGN(bool stats_match, 
stats_scan->FilterByStats(entry));
+        ASSERT_TRUE(stats_match);
+        ASSERT_OK_AND_ASSIGN(auto scan, CreateScan(predicate));
+        ASSERT_OK_AND_ASSIGN(bool keep, scan->FilterManifestEntry(entry));
+        ASSERT_TRUE(keep);
+    }
+
+    std::shared_ptr<arrow::DataType> rowkey_type_ = arrow::utf8();
+    static constexpr int32_t kNumBuckets = 4;
+    int64_t schema_id_ = 0;
+    bool extra_field_ = false;
+    std::shared_ptr<SchemaManager> schema_manager_;
+    std::shared_ptr<MemoryPool> pool_ = GetDefaultPool();
+    std::map<std::string, std::string> options_ = {{Options::BUCKET, "4"},
+                                                   {Options::BUCKET_KEY, 
"rowkey"}};
+};
+
+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, PreservesExplicitBucketFilter) {
+    BinaryRow key = BinaryRowGenerator::GenerateRow({std::string("key")}, 
pool_.get());
+    int32_t other_bucket = (DefaultBucketFunction().Bucket(key, kNumBuckets) + 
1) % kNumBuckets;
+    CheckBuckets(KeyEquals(), other_bucket, other_bucket);
+}
+
+TEST_F(AppendBucketPruningTest, DoesNotPruneWithoutCompleteEqualKeys) {
+    CheckBuckets(nullptr, std::nullopt);
+    CheckBuckets(PredicateBuilder::GreaterThan(0, "rowkey", FieldType::STRING,
+                                               Literal(FieldType::STRING, 
"key", 3)),
+                 std::nullopt);
+    options_[Options::BUCKET_KEY] = "rowkey,value";
+    CheckBuckets(KeyEquals(), std::nullopt);
+}
+
+TEST_F(AppendBucketPruningTest, DoesNotPruneBucketUnawareTable) {
+    options_[Options::BUCKET] = "-1";
+    CheckBuckets(KeyEquals(), std::nullopt);
+}
+
+TEST_F(AppendBucketPruningTest, UsesEachEntriesBucketCount) {
+    BinaryRow key = BinaryRowGenerator::GenerateRow({std::string("key")}, 
pool_.get());
+    for (int32_t total_buckets : {2, 4, 8, 17}) {
+        CheckBuckets(KeyEquals(), DefaultBucketFunction().Bucket(key, 
total_buckets), std::nullopt,
+                     total_buckets);
+    }
+}
+
+TEST_F(AppendBucketPruningTest, PrunesCompatibleHistoricalSchema) {
+    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;
+    extra_field_ = true;
+    BinaryRow key = BinaryRowGenerator::GenerateRow({std::string("key")}, 
pool_.get());
+    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, PreservesCrossScaleDecimalMatch) {
+    rowkey_type_ = arrow::decimal128(10, 2);
+    CheckMatchingValue(FieldType::DECIMAL, Decimal::FromUnscaledLong(12, 10, 
1),
+                       Decimal::FromUnscaledLong(120, 10, 2));
+}
+
+TEST_F(AppendBucketPruningTest, PreservesDifferentNaNPayloadMatch) {
+    rowkey_type_ = arrow::float64();
+    auto query_value = 
FloatingPointFromBits<double>(uint64_t{0x7ff8000000000000ULL});
+    auto stored_value = 
FloatingPointFromBits<double>(uint64_t{0x7ff8000000000001ULL});
+    CheckMatchingValue(FieldType::DOUBLE, query_value, stored_value);
+}
+
 TEST(AppendOnlyFileStoreScanTest, TestReconstructPredicateWithNonCastedFields) 
{
     std::string table_root =
         paimon::test::GetDataDir() +
diff --git a/src/paimon/core/operation/file_store_scan.cpp 
b/src/paimon/core/operation/file_store_scan.cpp
index befe7eb9..2b182107 100644
--- a/src/paimon/core/operation/file_store_scan.cpp
+++ b/src/paimon/core/operation/file_store_scan.cpp
@@ -33,6 +33,7 @@
 #include "paimon/common/data/binary_array.h"
 #include "paimon/common/data/blob_utils.h"
 #include "paimon/common/executor/future.h"
+#include "paimon/common/io/cache/cache_key.h"
 #include "paimon/common/predicate/literal_converter.h"
 #include "paimon/common/types/data_field.h"
 #include "paimon/common/utils/field_type_utils.h"
@@ -146,16 +147,20 @@ Result<std::shared_ptr<FileStoreScan::RawPlan>> 
FileStoreScan::CreatePlan() cons
         ReadManifests(&snapshot, &all_manifest_file_metas, 
&filtered_manifest_file_metas));
 
     std::vector<ManifestEntry> manifest_entries;
+    std::optional<int32_t> cache_bucket = bucket_filter_;
+    if (!cache_bucket && bucket_selector_) {
+        cache_bucket = bucket_selector_->Bucket(core_options_.GetBucket());
+    }
     const bool use_snapshot_live_manifest_cache =
         snapshot.has_value() && scan_mode_ == ScanMode::ALL &&
         core_options_.GetScanManifestEntryCacheMaxSnapshots() > 0 &&
         core_options_.GetCache() != nullptr && !table_path_.empty() &&
-        !row_range_index_.has_value() && bucket_filter_.has_value();
+        !row_range_index_.has_value() && cache_bucket.has_value();
     uint64_t lazy_decode_scanned_rows = 0;
     bool snapshot_cache_hit = false;
     if (use_snapshot_live_manifest_cache) {
         PAIMON_RETURN_NOT_OK(ReadManifestEntriesWithCache(snapshot.value(), 
all_manifest_file_metas,
-                                                          
bucket_filter_.value(), &manifest_entries,
+                                                          
cache_bucket.value(), &manifest_entries,
                                                           
&snapshot_cache_hit));
         lazy_decode_scanned_rows = manifest_entries.size();
         std::vector<ManifestEntry> filtered_entries;
@@ -325,7 +330,8 @@ Status FileStoreScan::ReadManifestEntries(const 
std::vector<ManifestFileMeta>& m
 }
 
 // Cache merged live manifest entries for one bucket before applying scan 
filters. Each cache value
-// keeps a bounded number of snapshot results for the same 
table/branch/bucket. Exact snapshot hits
+// 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
 // can be returned directly; cache misses rebuild the target snapshot bucket 
from the target
 // snapshot's data manifests.
 Status FileStoreScan::ReadManifestEntriesWithCache(
@@ -351,7 +357,7 @@ Status FileStoreScan::ReadManifestEntriesWithCache(
     // cache.
     std::vector<ManifestFileMeta> bucket_manifest_metas;
     for (const auto& meta : all_manifest_metas) {
-        if (MayContainBucket(meta, bucket)) {
+        if ((!bucket_filter_ && bucket_selector_) || MayContainBucket(meta, 
bucket)) {
             bucket_manifest_metas.push_back(meta);
         }
     }
@@ -368,8 +374,14 @@ Status FileStoreScan::ReadManifestEntriesWithCache(
     return Status::OK();
 }
 
-std::shared_ptr<CacheKey> 
FileStoreScan::SnapshotLiveManifestEntriesCacheKey(int32_t bucket) const {
-    return CacheKey::ForSnapshotLiveManifestEntries(
+std::shared_ptr<CacheKey> 
FileStoreScan::CreateSnapshotLiveManifestEntriesCacheKey(
+    int32_t bucket) const {
+    if (!bucket_filter_ && bucket_selector_) {
+        return SnapshotLiveManifestEntriesCacheKey::ForInferred(
+            table_path_, 
BranchManager::NormalizeBranch(core_options_.GetBranch()), bucket,
+            core_options_.GetBucket(), table_schema_->Id());
+    }
+    return SnapshotLiveManifestEntriesCacheKey::ForExplicit(
         table_path_, 
BranchManager::NormalizeBranch(core_options_.GetBranch()), bucket);
 }
 
@@ -378,7 +390,7 @@ Result<SnapshotLiveManifestEntries> 
FileStoreScan::LoadSnapshotLiveManifestEntri
     auto supplier = [](const std::shared_ptr<CacheKey>&) -> 
Result<std::shared_ptr<CacheValue>> {
         return std::shared_ptr<CacheValue>();
     };
-    std::shared_ptr<CacheKey> cache_key = 
SnapshotLiveManifestEntriesCacheKey(bucket);
+    std::shared_ptr<CacheKey> cache_key = 
CreateSnapshotLiveManifestEntriesCacheKey(bucket);
     const auto max_snapshots = 
core_options_.GetScanManifestEntryCacheMaxSnapshots();
     Result<std::shared_ptr<CacheValue>> cache_result =
         core_options_.GetCache()->Get(cache_key, supplier);
@@ -401,15 +413,17 @@ Status FileStoreScan::StoreSnapshotLiveManifestEntries(
     }
     auto cache_value =
         
std::make_shared<CacheValue>(MemorySegment::Wrap(bytes_result.value()), 
CacheCallback());
-    Status status =
-        
core_options_.GetCache()->Put(SnapshotLiveManifestEntriesCacheKey(bucket), 
cache_value);
+    Status status = 
core_options_.GetCache()->Put(CreateSnapshotLiveManifestEntriesCacheKey(bucket),
+                                                  cache_value);
     return status.ok() ? status : Status::OK();
 }
 
 Status FileStoreScan::ReadAndMergeBucketFileEntries(
     const std::vector<ManifestFileMeta>& manifest_metas, int32_t bucket,
     std::vector<ManifestEntry>* merged_entries) const {
-    if (core_options_.ScanManifestEntryLazyDecodeEnabled()) {
+    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()) {
         std::vector<std::future<Result<std::vector<ManifestEntry>>>> futures;
         futures.reserve(manifest_metas.size());
         for (const auto& meta : manifest_metas) {
@@ -441,7 +455,9 @@ Status FileStoreScan::ReadAndMergeBucketFileEntries(
     PAIMON_RETURN_NOT_OK(ReadFileEntries(manifest_metas, &entries, 
/*apply_scan_filter=*/false));
     unmerged_entries.reserve(entries.size());
     for (auto& entry : entries) {
-        if (entry.Bucket() == bucket) {
+        if (entry.Bucket() == bucket ||
+            (inferred_bucket && (entry.TotalBuckets() != 
core_options_.GetBucket() ||
+                                 entry.File()->schema_id != 
table_schema_->Id()))) {
             unmerged_entries.emplace_back(std::move(entry));
         }
     }
@@ -557,12 +573,43 @@ 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 (level_filter_ != nullptr && !level_filter_(entry.Level())) {
         return false;
     }
     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) {
@@ -602,6 +649,19 @@ Status FileStoreScan::SplitAndSetFilter(const 
std::vector<std::string>& partitio
         }
     }
     bucket_filter_ = scan_filters->GetBucketFilter();
+    const auto& bucket_keys = table_schema_->BucketKeys();
+    if (predicates_ && !bucket_filter_ && core_options_.GetBucket() > 0 && 
!bucket_keys.empty()) {
+        std::vector<std::shared_ptr<arrow::DataType>> bucket_key_types;
+        bucket_key_types.reserve(bucket_keys.size());
+        for (const auto& key : bucket_keys) {
+            PAIMON_ASSIGN_OR_RAISE(DataField field, 
table_schema_->GetField(key));
+            bucket_key_types.push_back(field.Type());
+        }
+        PAIMON_ASSIGN_OR_RAISE(bucket_selector_,
+                               BucketSelectConverter::ConvertToSelector(
+                                   predicates_, bucket_keys, bucket_key_types,
+                                   core_options_.GetBucketFunctionType(), 
pool_.get()));
+    }
     if (!scan_filters->GetPartitionFilters().empty()) {
         PAIMON_ASSIGN_OR_RAISE(
             partition_filter_,
diff --git a/src/paimon/core/operation/file_store_scan.h 
b/src/paimon/core/operation/file_store_scan.h
index ea963a71..83c18d49 100644
--- a/src/paimon/core/operation/file_store_scan.h
+++ b/src/paimon/core/operation/file_store_scan.h
@@ -34,8 +34,10 @@
 #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"
 #include "paimon/core/core_options.h"
 #include "paimon/core/manifest/manifest_entry.h"
 #include "paimon/core/manifest/manifest_file.h"
@@ -241,14 +243,6 @@ class FileStoreScan {
                              const std::shared_ptr<arrow::Schema>& 
arrow_schema,
                              const std::shared_ptr<ScanFilter>& scan_filters);
 
-    /// Set the bucket filter derived from predicate analysis (e.g., 
BucketSelectConverter).
-    /// Only sets the filter if no explicit bucket filter was already set.
-    void SetBucketFilterIfAbsent(int32_t bucket) {
-        if (!bucket_filter_.has_value()) {
-            bucket_filter_ = bucket;
-        }
-    }
-
     // When schema evolves, predicates might contain fields requiring casting. 
To avoid false
     // negatives when filtering by stats, we exclude those fields from 
predicate.
     static Result<std::shared_ptr<Predicate>> 
ReconstructPredicateWithNonCastedFields(
@@ -271,7 +265,7 @@ class FileStoreScan {
                                         int32_t bucket,
                                         std::vector<ManifestEntry>* 
manifest_entries,
                                         bool* cache_hit) const;
-    std::shared_ptr<CacheKey> SnapshotLiveManifestEntriesCacheKey(int32_t 
bucket) const;
+    std::shared_ptr<CacheKey> 
CreateSnapshotLiveManifestEntriesCacheKey(int32_t bucket) const;
     Result<SnapshotLiveManifestEntries> 
LoadSnapshotLiveManifestEntries(int32_t bucket) const;
     Status StoreSnapshotLiveManifestEntries(int32_t bucket,
                                             const SnapshotLiveManifestEntries& 
entries) const;
@@ -300,6 +294,7 @@ 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_;
@@ -321,6 +316,8 @@ class FileStoreScan {
     std::shared_ptr<PredicateFilter> partition_filter_;
     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/operation/key_value_file_store_scan.cpp 
b/src/paimon/core/operation/key_value_file_store_scan.cpp
index 54af98a0..c8b8bacc 100644
--- a/src/paimon/core/operation/key_value_file_store_scan.cpp
+++ b/src/paimon/core/operation/key_value_file_store_scan.cpp
@@ -31,7 +31,6 @@
 #include "paimon/common/predicate/predicate_filter.h"
 #include "paimon/common/types/data_field.h"
 #include "paimon/common/utils/object_utils.h"
-#include "paimon/core/bucket/bucket_select_converter.h"
 #include "paimon/core/core_options.h"
 #include "paimon/core/io/data_file_meta.h"
 #include "paimon/core/options/merge_engine.h"
@@ -121,26 +120,6 @@ Status KeyValueFileStoreScan::SplitAndSetKeyValueFilter(
             return Status::Invalid("invalid key predicate, cannot cast to 
PredicateFilter");
         }
         WithKeyFilter(key_filter);
-
-        // Bucket select conversion: derive target bucket from EQUAL 
predicates on bucket keys
-        const auto& bucket_keys = table_schema_->BucketKeys();
-        int32_t num_buckets = core_options_.GetBucket();
-        if (num_buckets > 0 && !bucket_keys.empty()) {
-            std::vector<std::shared_ptr<arrow::DataType>> 
bucket_key_arrow_types;
-            bucket_key_arrow_types.reserve(bucket_keys.size());
-            for (const auto& key : bucket_keys) {
-                PAIMON_ASSIGN_OR_RAISE(DataField field, 
table_schema_->GetField(key));
-                bucket_key_arrow_types.push_back(field.Type());
-            }
-            PAIMON_ASSIGN_OR_RAISE(
-                std::optional<int32_t> selected_bucket,
-                BucketSelectConverter::Convert(key_predicate, bucket_keys, 
bucket_key_arrow_types,
-                                               
core_options_.GetBucketFunctionType(), num_buckets,
-                                               pool_.get()));
-            if (selected_bucket.has_value()) {
-                SetBucketFilterIfAbsent(selected_bucket.value());
-            }
-        }
     }
 
     // Only set value filtering when there are predicates on non-primary-key 
fields.
diff --git a/src/paimon/core/operation/key_value_file_store_scan_test.cpp 
b/src/paimon/core/operation/key_value_file_store_scan_test.cpp
index f6c13cb3..ae53d8aa 100644
--- a/src/paimon/core/operation/key_value_file_store_scan_test.cpp
+++ b/src/paimon/core/operation/key_value_file_store_scan_test.cpp
@@ -27,6 +27,7 @@
 #include "paimon/common/data/binary_row.h"
 #include "paimon/common/predicate/predicate_filter.h"
 #include "paimon/common/types/data_field.h"
+#include "paimon/core/bucket/default_bucket_function.h"
 #include "paimon/core/core_options.h"
 #include "paimon/core/io/data_file_meta.h"
 #include "paimon/core/manifest/file_kind.h"
@@ -44,6 +45,7 @@
 #include "paimon/defs.h"
 #include "paimon/executor.h"
 #include "paimon/format/file_format.h"
+#include "paimon/fs/local/local_file_system.h"
 #include "paimon/memory/memory_pool.h"
 #include "paimon/metrics.h"
 #include "paimon/predicate/literal.h"
@@ -58,6 +60,51 @@ class Schema;
 }  // namespace arrow
 
 namespace paimon::test {
+TEST(KeyValueBucketPruningTest, UsesEachEntriesBucketCount) {
+    auto pool = GetDefaultPool();
+    auto arrow_schema = DataField::ConvertDataFieldsToArrowSchema(
+        {DataField(0, arrow::field("rowkey", arrow::utf8()))});
+    std::map<std::string, std::string> options = {{Options::BUCKET, "4"}};
+    auto test_dir = UniqueTestDirectory::Create("local");
+    auto manager =
+        std::make_shared<SchemaManager>(std::make_shared<LocalFileSystem>(), 
test_dir->Str());
+    ASSERT_OK(manager->CreateTable(arrow_schema, {}, {"rowkey"}, options));
+    for (int64_t current_schema_id : {0, 1}) {
+        ASSERT_OK_AND_ASSIGN(
+            std::shared_ptr<TableSchema> schema,
+            TableSchema::Create(current_schema_id, arrow_schema, {}, 
{"rowkey"}, options));
+        ASSERT_OK_AND_ASSIGN(auto core_options, CoreOptions::FromMap(options));
+        auto predicate = PredicateBuilder::Equal(0, "rowkey", 
FieldType::STRING,
+                                                 Literal(FieldType::STRING, 
"key", 3));
+        auto filters = std::make_shared<ScanFilter>(
+            predicate, std::vector<std::map<std::string, std::string>>(), 
std::nullopt);
+        ASSERT_OK_AND_ASSIGN(
+            auto scan,
+            KeyValueFileStoreScan::Create(nullptr, manager, nullptr, nullptr, 
schema, arrow_schema,
+                                          filters, core_options, 
CreateDefaultExecutor(), pool));
+        SimpleStats stats = 
BinaryRowGenerator::GenerateStats({std::string("a")},
+                                                              
{std::string("z")}, {0}, pool.get());
+        ASSERT_OK_AND_ASSIGN(
+            auto file,
+            DataFileMeta::ForAppend("data.parquet", 100, 10, stats, 0, 9, 0, 
std::nullopt,
+                                    std::nullopt, std::nullopt, std::nullopt, 
std::nullopt));
+        file->key_stats = stats;
+        BinaryRow key = BinaryRowGenerator::GenerateRow({std::string("key")}, 
pool.get());
+        for (int32_t total_buckets : {2, 4, 8, 17}) {
+            int32_t expected = DefaultBucketFunction().Bucket(key, 
total_buckets);
+            for (int32_t bucket = 0; bucket < total_buckets; ++bucket) {
+                ManifestEntry entry(FileKind::Add(), BinaryRow::EmptyRow(), 
bucket, total_buckets,
+                                    file);
+                ASSERT_OK_AND_ASSIGN(bool stats_match, 
scan->FilterByStats(entry));
+                ASSERT_TRUE(stats_match);
+                ASSERT_OK_AND_ASSIGN(bool keep, 
scan->FilterManifestEntry(entry));
+                ASSERT_EQ(keep, bucket == expected)
+                    << "bucket=" << bucket << " total=" << total_buckets;
+            }
+        }
+    }
+}
+
 class KeyValueFileStoreScanTest : public testing::Test {
  public:
     void SetUp() override {
diff --git a/src/paimon/core/schema/schema_manager.cpp 
b/src/paimon/core/schema/schema_manager.cpp
index ede262f2..4ec1f8ec 100644
--- a/src/paimon/core/schema/schema_manager.cpp
+++ b/src/paimon/core/schema/schema_manager.cpp
@@ -71,16 +71,16 @@ Result<std::optional<std::shared_ptr<TableSchema>>> 
SchemaManager::Latest() cons
 }
 
 Result<std::shared_ptr<TableSchema>> SchemaManager::ReadSchema(int64_t 
schema_id) const {
-    auto iter = schema_cache_.find(schema_id);
-    if (iter != schema_cache_.end()) {
-        return iter->second;
+    auto cached = schema_cache_.Find(schema_id);
+    if (cached) {
+        return cached.value();
     }
     auto path = ToSchemaPath(schema_id);
     std::string content;
     PAIMON_RETURN_NOT_OK(file_system_->ReadFile(path, &content));
     PAIMON_ASSIGN_OR_RAISE(std::shared_ptr<TableSchema> schema,
                            TableSchema::CreateFromJson(content));
-    schema_cache_[schema_id] = schema;
+    schema_cache_.Insert(schema_id, schema);
     return schema;
 }
 
diff --git a/src/paimon/core/schema/schema_manager.h 
b/src/paimon/core/schema/schema_manager.h
index 382fa303..7480ec5d 100644
--- a/src/paimon/core/schema/schema_manager.h
+++ b/src/paimon/core/schema/schema_manager.h
@@ -26,6 +26,7 @@
 #include <string>
 #include <vector>
 
+#include "paimon/common/utils/concurrent_hash_map.h"
 #include "paimon/core/schema/table_schema.h"
 #include "paimon/fs/file_system.h"
 #include "paimon/result.h"
@@ -69,7 +70,7 @@ class SchemaManager {
     std::shared_ptr<FileSystem> file_system_;
     std::string table_root_;
     const std::string branch_;
-    mutable std::map<int64_t, std::shared_ptr<TableSchema>> schema_cache_;
+    mutable ConcurrentHashMap<int64_t, std::shared_ptr<TableSchema>> 
schema_cache_;
 };
 
 }  // namespace paimon
diff --git a/src/paimon/core/schema/schema_manager_test.cpp 
b/src/paimon/core/schema/schema_manager_test.cpp
index 83f995a9..0b078462 100644
--- a/src/paimon/core/schema/schema_manager_test.cpp
+++ b/src/paimon/core/schema/schema_manager_test.cpp
@@ -19,6 +19,7 @@
 
 #include "paimon/core/schema/schema_manager.h"
 
+#include <future>
 #include <set>
 #include <utility>
 
@@ -30,6 +31,22 @@
 
 namespace paimon::test {
 
+TEST(SchemaManagerTest, ConcurrentHistoricalSchemaReads) {
+    SchemaManager manager(
+        std::make_shared<LocalFileSystem>(),
+        GetDataDir() + 
"/orc/pk_table_with_alter_table.db/pk_table_with_alter_table/");
+    std::vector<std::future<Result<std::shared_ptr<TableSchema>>>> reads;
+    for (int32_t i = 0; i < 16; ++i) {
+        reads.push_back(
+            std::async(std::launch::async, [&manager, i]() { return 
manager.ReadSchema(i % 2); }));
+    }
+    for (int32_t i = 0; i < 16; ++i) {
+        ASSERT_OK_AND_ASSIGN(auto schema, reads[i].get());
+        ASSERT_EQ(schema->Id(), i % 2);
+    }
+    ASSERT_EQ(manager.schema_cache_.Size(), 2);
+}
+
 TEST(SchemaManagerTest, TestSimple) {
     auto fs = std::make_shared<LocalFileSystem>();
     std::string table_root =
@@ -91,7 +108,7 @@ TEST(SchemaManagerTest, TestSimple) {
     })";
     ASSERT_OK_AND_ASSIGN(auto expected_schema, 
TableSchema::CreateFromJson(schema_json));
     ASSERT_EQ(*ret, *expected_schema);
-    ASSERT_FALSE(manager.schema_cache_.empty());
+    ASSERT_GT(manager.schema_cache_.Size(), 0);
     ASSERT_EQ(*manager.ReadSchema(/*schema_id=*/1).value(), *expected_schema);
     ASSERT_EQ(*(manager.Latest().value().value()), *expected_schema);
 }
diff --git a/test/inte/scan_and_read_inte_test.cpp 
b/test/inte/scan_and_read_inte_test.cpp
index c4c50cc4..ad134540 100644
--- a/test/inte/scan_and_read_inte_test.cpp
+++ b/test/inte/scan_and_read_inte_test.cpp
@@ -29,8 +29,12 @@
 #include <vector>
 
 #include "arrow/api.h"
+#include "arrow/c/bridge.h"
 #include "arrow/ipc/json_simple.h"
+#include "fmt/format.h"
+#include "fmt/ranges.h"
 #include "gtest/gtest.h"
+#include "paimon/bucket/bucket_id_calculator.h"
 #include "paimon/common/factories/io_hook.h"
 #include "paimon/common/io/cache/lru_cache.h"
 #include "paimon/common/table/special_fields.h"
@@ -56,6 +60,7 @@
 #include "paimon/scan_context.h"
 #include "paimon/status.h"
 #include "paimon/table/source/plan.h"
+#include "paimon/table/source/scan_metrics.h"
 #include "paimon/table/source/startup_mode.h"
 #include "paimon/table/source/table_read.h"
 #include "paimon/table/source/table_scan.h"
@@ -421,6 +426,136 @@ TEST_P(ScanAndReadInteTest, TestWithAppendSnapshot3) {
     ASSERT_TRUE(expected->Equals(read_result)) << read_result->ToString();
 }
 
+TEST_P(ScanAndReadInteTest, TestWithAppendBucketKeyPointLookup) {
+    for (const auto& key_type : {arrow::utf8(), arrow::binary()}) {
+        SCOPED_TRACE(key_type->ToString());
+        auto test_dir = UniqueTestDirectory::Create("local");
+        arrow::FieldVector fields = {arrow::field("rowkey", key_type),
+                                     arrow::field("value", arrow::int32())};
+        std::map<std::string, std::string> options = {{Options::FILE_FORMAT, 
FileFormat()},
+                                                      {Options::FILE_SYSTEM, 
"local"},
+                                                      {Options::BUCKET, "4"},
+                                                      {Options::BUCKET_KEY, 
"rowkey"}};
+        ASSERT_OK_AND_ASSIGN(
+            auto helper, TestHelper::Create(test_dir->Str(), 
arrow::schema(fields), {}, {}, options,
+                                            /*is_streaming_mode=*/false));
+        std::string table_path = test_dir->Str() + "/foo.db/bar";
+        // Route writes through the public bucket calculator, as a KV client 
does.
+        constexpr int32_t kNumKeys = 64;
+        constexpr int32_t kNumBuckets = 4;
+        std::vector<std::string> keys;
+        for (int32_t i = 0; i < kNumKeys; ++i) {
+            keys.push_back(fmt::format(R"(["key{:03}"])", i));
+        }
+        auto key_array = arrow::ipc::internal::json::ArrayFromJSON(
+                             arrow::struct_({fields[0]}), fmt::format("[{}]", 
fmt::join(keys, ",")))
+                             .ValueOrDie();
+        ::ArrowArray c_keys;
+        ::ArrowSchema c_schema;
+        ASSERT_TRUE(arrow::ExportArray(*key_array, &c_keys, &c_schema).ok());
+        ASSERT_OK_AND_ASSIGN(auto calculator,
+                             BucketIdCalculator::Create(false, kNumBuckets, 
GetDefaultPool()));
+        std::vector<int32_t> bucket_ids(kNumKeys);
+        ASSERT_OK(calculator->CalculateBucketIds(&c_keys, &c_schema, 
bucket_ids.data()));
+        std::vector<std::vector<std::string>> rows(kNumBuckets);
+        for (int32_t i = 0; i < kNumKeys; ++i) {
+            rows[bucket_ids[i]].push_back(fmt::format(R"(["key{:03}", {}])", 
i, i));
+        }
+        std::vector<std::unique_ptr<RecordBatch>> batches;
+        for (int32_t bucket = 0; bucket < kNumBuckets; ++bucket) {
+            ASSERT_FALSE(rows[bucket].empty());
+            ASSERT_OK_AND_ASSIGN(
+                auto batch, TestHelper::MakeRecordBatch(
+                                arrow::struct_(fields),
+                                fmt::format("[{}]", fmt::join(rows[bucket], 
",")), {}, bucket, {}));
+            batches.push_back(std::move(batch));
+        }
+        ASSERT_OK(helper->WriteAndCommit(std::move(batches), 0, std::nullopt));
+
+        FieldType field_type =
+            key_type->id() == arrow::Type::STRING ? FieldType::STRING : 
FieldType::BINARY;
+        auto predicate =
+            PredicateBuilder::Equal(0, "rowkey", field_type, 
Literal(field_type, "key032", 6));
+        // All buckets have overlapping stats for this key. An explicit filter 
bypasses
+        // inference, proving that ordinary statistics cannot account for the 
pruning.
+        for (int32_t bucket = 0; bucket < kNumBuckets; ++bucket) {
+            ScanContextBuilder builder(table_path);
+            builder.SetPredicate(predicate).SetBucketFilter(bucket);
+            ASSERT_OK_AND_ASSIGN(auto context, FinishScanContext(builder));
+            ASSERT_OK_AND_ASSIGN(auto scan, 
TableScan::Create(std::move(context)));
+            ASSERT_OK_AND_ASSIGN(auto plan, scan->CreatePlan());
+
+            ASSERT_FALSE(plan->Splits().empty());
+        }
+        int32_t other_index = -1;
+        for (int32_t i = 0; i < kNumKeys; ++i) {
+            if (bucket_ids[i] % 2 == bucket_ids[32] % 2 && bucket_ids[i] != 
bucket_ids[32]) {
+                other_index = i;
+                break;
+            }
+        }
+        ASSERT_GE(other_index, 0);
+        // These keys share a current two-bucket selection but need different 
historical buckets.
+        for (int32_t lookup_index : {32, other_index}) {
+            // The data was written with four buckets. Read it with rescaled 
table options too.
+            for (const std::string current_bucket_count : {"2", "4", "8", 
"17"}) {
+                SCOPED_TRACE(current_bucket_count);
+                const std::string lookup_key = fmt::format("key{:03}", 
lookup_index);
+                auto lookup_predicate = PredicateBuilder::Equal(
+                    0, "rowkey", field_type,
+                    Literal(field_type, lookup_key.data(), lookup_key.size()));
+                ScanContextBuilder scan_builder(table_path);
+                scan_builder.SetPredicate(lookup_predicate)
+                    .AddOption(Options::BUCKET, current_bucket_count);
+                ASSERT_OK_AND_ASSIGN(auto scan_context, 
FinishScanContext(scan_builder));
+                ASSERT_OK_AND_ASSIGN(auto scan, 
TableScan::Create(std::move(scan_context)));
+                ASSERT_OK_AND_ASSIGN(auto plan, scan->CreatePlan());
+                if (EnableSnapshotLiveManifestCache()) {
+                    ASSERT_OK_AND_ASSIGN(
+                        uint64_t enabled,
+                        
scan->GetMetrics()->GetCounter(ScanMetrics::LAST_SNAPSHOT_CACHE_ENABLED));
+                    ASSERT_EQ(enabled, 1);
+                    ScanContextBuilder cached_builder(table_path);
+                    cached_builder.SetPredicate(lookup_predicate)
+                        .AddOption(Options::BUCKET, current_bucket_count);
+                    ASSERT_OK_AND_ASSIGN(auto cached_context, 
FinishScanContext(cached_builder));
+                    ASSERT_OK_AND_ASSIGN(auto cached_scan,
+                                         
TableScan::Create(std::move(cached_context)));
+                    ASSERT_OK_AND_ASSIGN(plan, cached_scan->CreatePlan());
+                    ASSERT_OK_AND_ASSIGN(uint64_t hit, 
cached_scan->GetMetrics()->GetCounter(
+                                                           
ScanMetrics::LAST_SNAPSHOT_CACHE_HIT));
+                    ASSERT_EQ(hit, 1);
+                }
+
+                ASSERT_FALSE(plan->Splits().empty());
+                for (const auto& split : plan->Splits()) {
+                    auto data_split = 
std::dynamic_pointer_cast<DataSplitImpl>(split);
+                    ASSERT_TRUE(data_split);
+                    ASSERT_EQ(data_split->Bucket(), bucket_ids[lookup_index]);
+                }
+                ReadContextBuilder read_builder(table_path);
+                AddReadOptionsForPrefetch(&read_builder);
+                
read_builder.SetPredicate(lookup_predicate).EnablePredicateFilter(true);
+                ASSERT_OK_AND_ASSIGN(auto read_context, read_builder.Finish());
+                ASSERT_OK_AND_ASSIGN(auto read, 
TableRead::Create(std::move(read_context)));
+                ASSERT_OK_AND_ASSIGN(auto reader, 
read->CreateReader(plan->Splits()));
+                ASSERT_OK_AND_ASSIGN(auto result,
+                                     
ReadResultCollector::CollectResult(std::move(reader)));
+                auto expected_fields = fields;
+                expected_fields.insert(expected_fields.begin(),
+                                       arrow::field("_VALUE_KIND", 
arrow::int8()));
+                auto expected_array =
+                    arrow::ipc::internal::json::ArrayFromJSON(
+                        arrow::struct_(expected_fields),
+                        fmt::format(R"([[0, "{}", {}]])", lookup_key, 
lookup_index))
+                        .ValueOrDie();
+                auto expected = 
std::make_shared<arrow::ChunkedArray>(expected_array);
+                ASSERT_TRUE(expected->Equals(result)) << result->ToString();
+            }
+        }
+    }
+}
+
 TEST_P(ScanAndReadInteTest, TestWithAppendSnapshot5) {
     auto file_format = FileFormat();
     std::string table_path = GetDataDir() + "/" + file_format + 
"/append_09.db/append_09";
@@ -3025,46 +3160,68 @@ TEST_P(ScanAndReadInteTest, 
TestWithPKBucketSelectByPredicate) {
     auto predicate = PredicateBuilder::Equal(/*field_index=*/2, 
/*field_name=*/"f2", FieldType::INT,
                                              Literal(static_cast<int32_t>(0)));
 
-    ScanContextBuilder scan_context_builder(table_path);
-    scan_context_builder.AddOption(Options::SCAN_SNAPSHOT_ID, "6");
-    scan_context_builder.SetPartitionFilter({{{"f1", "10"}}});
-    scan_context_builder.SetPredicate(predicate);
-    ASSERT_OK_AND_ASSIGN(auto scan_context, 
FinishScanContext(scan_context_builder));
-    ASSERT_OK_AND_ASSIGN(auto table_scan, 
TableScan::Create(std::move(scan_context)));
+    // Historical files use two buckets even when the current option has 
changed.
+    for (const std::string current_bucket_count : {"2", "4", "8", "17"}) {
+        SCOPED_TRACE(current_bucket_count);
+        ScanContextBuilder scan_context_builder(table_path);
+        scan_context_builder.AddOption(Options::SCAN_SNAPSHOT_ID, "6");
+        scan_context_builder.AddOption(Options::BUCKET, current_bucket_count);
+        scan_context_builder.SetPartitionFilter({{{"f1", "10"}}});
+        scan_context_builder.SetPredicate(predicate);
+        ASSERT_OK_AND_ASSIGN(auto scan_context, 
FinishScanContext(scan_context_builder));
+        ASSERT_OK_AND_ASSIGN(auto table_scan, 
TableScan::Create(std::move(scan_context)));
 
-    ReadContextBuilder read_context_builder(table_path);
-    AddReadOptionsForPrefetch(&read_context_builder);
-    read_context_builder.SetPredicate(predicate);
-    ASSERT_OK_AND_ASSIGN(auto read_context, read_context_builder.Finish());
-    ASSERT_OK_AND_ASSIGN(auto table_read, 
TableRead::Create(std::move(read_context)));
+        ReadContextBuilder read_context_builder(table_path);
+        AddReadOptionsForPrefetch(&read_context_builder);
+        read_context_builder.SetPredicate(predicate);
+        ASSERT_OK_AND_ASSIGN(auto read_context, read_context_builder.Finish());
+        ASSERT_OK_AND_ASSIGN(auto table_read, 
TableRead::Create(std::move(read_context)));
 
-    ASSERT_OK_AND_ASSIGN(auto result_plan, table_scan->CreatePlan());
-    ASSERT_EQ(result_plan->SnapshotId().value(), 6);
+        ASSERT_OK_AND_ASSIGN(auto result_plan, table_scan->CreatePlan());
+        if (EnableSnapshotLiveManifestCache()) {
+            ASSERT_OK_AND_ASSIGN(uint64_t enabled, 
table_scan->GetMetrics()->GetCounter(
+                                                       
ScanMetrics::LAST_SNAPSHOT_CACHE_ENABLED));
+            ASSERT_EQ(enabled, 1);
+            ScanContextBuilder cached_builder(table_path);
+            cached_builder.AddOption(Options::SCAN_SNAPSHOT_ID, "6")
+                .AddOption(Options::BUCKET, current_bucket_count)
+                .SetPartitionFilter({{{"f1", "10"}}})
+                .SetPredicate(predicate);
+            ASSERT_OK_AND_ASSIGN(auto cached_context, 
FinishScanContext(cached_builder));
+            ASSERT_OK_AND_ASSIGN(auto cached_scan, 
TableScan::Create(std::move(cached_context)));
+            ASSERT_OK_AND_ASSIGN(result_plan, cached_scan->CreatePlan());
+            ASSERT_OK_AND_ASSIGN(uint64_t hit, 
cached_scan->GetMetrics()->GetCounter(
+                                                   
ScanMetrics::LAST_SNAPSHOT_CACHE_HIT));
+            ASSERT_EQ(hit, 1);
+        }
 
-    // Verify all returned splits are from bucket 1 (f2=0 hashes to bucket 1)
-    auto splits = result_plan->Splits();
-    ASSERT_FALSE(splits.empty());
-    for (const auto& split : splits) {
-        auto data_split = std::dynamic_pointer_cast<DataSplitImpl>(split);
-        ASSERT_TRUE(data_split);
-        ASSERT_EQ(data_split->Bucket(), 1);
-    }
+        ASSERT_EQ(result_plan->SnapshotId().value(), 6);
 
-    ASSERT_OK_AND_ASSIGN(auto batch_reader, table_read->CreateReader(splits));
-    ASSERT_OK_AND_ASSIGN(auto read_result,
-                         
ReadResultCollector::CollectResult(std::move(batch_reader)));
+        // Verify all returned splits are from bucket 1 (f2=0 hashes to bucket 
1)
+        auto splits = result_plan->Splits();
+        ASSERT_FALSE(splits.empty());
+        for (const auto& split : splits) {
+            auto data_split = std::dynamic_pointer_cast<DataSplitImpl>(split);
+            ASSERT_TRUE(data_split);
+            ASSERT_EQ(data_split->Bucket(), 1);
+        }
 
-    // Only rows with f2=0 in partition f1=10 should be returned
-    auto expected = std::make_shared<arrow::ChunkedArray>(
-        arrow::ipc::internal::json::ArrayFromJSON(arrow_data_type_, R"([
+        ASSERT_OK_AND_ASSIGN(auto batch_reader, 
table_read->CreateReader(splits));
+        ASSERT_OK_AND_ASSIGN(auto read_result,
+                             
ReadResultCollector::CollectResult(std::move(batch_reader)));
+
+        // Only rows with f2=0 in partition f1=10 should be returned
+        auto expected = std::make_shared<arrow::ChunkedArray>(
+            arrow::ipc::internal::json::ArrayFromJSON(arrow_data_type_, R"([
 [0, "Alex", 10, 0, 16.1],
 [0, "Bob", 10, 0, 12.1],
 [0, "David", 10, 0, 17.1],
 [0, "Emily", 10, 0, 13.1]
    ])")
-            .ValueOrDie());
-    ASSERT_TRUE(expected);
-    ASSERT_TRUE(expected->Equals(read_result)) << read_result->ToString();
+                .ValueOrDie());
+        ASSERT_TRUE(expected);
+        ASSERT_TRUE(expected->Equals(read_result)) << read_result->ToString();
+    }
 }
 
 TEST_P(ScanAndReadInteTest, TestReadNullableMapKey) {

Reply via email to