[core] Unify block cache ownership and avoid redundant SST reads (#9663)
diff --git a/paimon-common/src/main/java/org/apache/paimon/io/cache/Cache.java b/paimon-common/src/main/java/org/apache/paimon/io/cache/Cache.java
index 8f96cdf..d100f6b 100644
--- a/paimon-common/src/main/java/org/apache/paimon/io/cache/Cache.java
+++ b/paimon-common/src/main/java/org/apache/paimon/io/cache/Cache.java
@@ -18,7 +18,7 @@
 
 package org.apache.paimon.io.cache;
 
-import org.apache.paimon.memory.MemorySegment;
+import org.apache.paimon.memory.MemorySlice;
 
 import javax.annotation.Nullable;
 
@@ -28,6 +28,10 @@
 /** Cache interface in Paimon. */
 public interface Cache {
 
+    /** Looks up an entry without loading it, recording an access according to the cache policy. */
+    @Nullable
+    CacheValue getIfPresent(CacheKey key);
+
     @Nullable
     CacheValue get(CacheKey key, Function<CacheKey, CacheValue> supplier);
 
@@ -44,11 +48,11 @@
     /** Value for cache. */
     class CacheValue {
 
-        final MemorySegment segment;
+        final MemorySlice slice;
         final CacheCallback callback;
 
-        CacheValue(MemorySegment segment, CacheCallback callback) {
-            this.segment = segment;
+        CacheValue(MemorySlice slice, CacheCallback callback) {
+            this.slice = slice;
             this.callback = callback;
         }
     }
diff --git a/paimon-common/src/main/java/org/apache/paimon/io/cache/CacheBuilder.java b/paimon-common/src/main/java/org/apache/paimon/io/cache/CacheBuilder.java
index 9c4782e..11f9b6b 100644
--- a/paimon-common/src/main/java/org/apache/paimon/io/cache/CacheBuilder.java
+++ b/paimon-common/src/main/java/org/apache/paimon/io/cache/CacheBuilder.java
@@ -23,6 +23,8 @@
 import org.apache.paimon.shade.caffeine2.com.github.benmanes.caffeine.cache.Caffeine;
 import org.apache.paimon.shade.caffeine2.com.github.benmanes.caffeine.cache.RemovalCause;
 
+import java.util.function.BiConsumer;
+
 /** Builder for a Caffeine cache. */
 public class CacheBuilder {
     private MemorySize memorySize;
@@ -37,22 +39,28 @@
     }
 
     public Cache build() {
+        return build(
+                (key, value) -> {
+                    if (value != null) {
+                        value.callback.onRemoval(key);
+                    }
+                });
+    }
+
+    Cache build(BiConsumer<CacheKey, Cache.CacheValue> onRemoval) {
         return new CaffeineCache(
                 Caffeine.newBuilder()
                         .weigher(CacheBuilder::weigh)
                         .maximumWeight(memorySize.getBytes())
-                        .removalListener(this::onRemoval)
+                        .removalListener(
+                                (CacheKey key, Cache.CacheValue value, RemovalCause cause) ->
+                                        onRemoval.accept(key, value))
                         .executor(Runnable::run)
                         .build());
     }
 
-    private void onRemoval(CacheKey key, Cache.CacheValue value, RemovalCause cause) {
-        if (value != null) {
-            value.callback.onRemoval(key);
-        }
-    }
-
     private static int weigh(CacheKey cacheKey, Cache.CacheValue cacheValue) {
-        return cacheValue.segment.size();
+        // A slice can exclude a trailer while retaining the complete heap allocation.
+        return cacheValue.slice.segment().size();
     }
 }
diff --git a/paimon-common/src/main/java/org/apache/paimon/io/cache/CacheKey.java b/paimon-common/src/main/java/org/apache/paimon/io/cache/CacheKey.java
index caad913..c038196 100644
--- a/paimon-common/src/main/java/org/apache/paimon/io/cache/CacheKey.java
+++ b/paimon-common/src/main/java/org/apache/paimon/io/cache/CacheKey.java
@@ -58,6 +58,10 @@
             this.hashCode = 31 * hashCode + Boolean.hashCode(isIndex);
         }
 
+        public Path filePath() {
+            return filePath;
+        }
+
         public long position() {
             return position;
         }
diff --git a/paimon-common/src/main/java/org/apache/paimon/io/cache/CacheManager.java b/paimon-common/src/main/java/org/apache/paimon/io/cache/CacheManager.java
index 4fe040e..1eacce2 100644
--- a/paimon-common/src/main/java/org/apache/paimon/io/cache/CacheManager.java
+++ b/paimon-common/src/main/java/org/apache/paimon/io/cache/CacheManager.java
@@ -19,14 +19,26 @@
 package org.apache.paimon.io.cache;
 
 import org.apache.paimon.annotation.VisibleForTesting;
+import org.apache.paimon.fs.Path;
+import org.apache.paimon.io.cache.Cache.CacheValue;
+import org.apache.paimon.io.cache.CacheKey.PositionCacheKey;
 import org.apache.paimon.memory.MemorySegment;
+import org.apache.paimon.memory.MemorySlice;
 import org.apache.paimon.options.MemorySize;
+import org.apache.paimon.utils.ExceptionUtils;
 import org.apache.paimon.utils.Preconditions;
 
 import org.slf4j.Logger;
 import org.slf4j.LoggerFactory;
 
+import javax.annotation.Nullable;
+
 import java.io.IOException;
+import java.util.ArrayList;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.function.Function;
 
 import static org.apache.paimon.utils.Preconditions.checkNotNull;
 
@@ -35,14 +47,16 @@
 
     private static final Logger LOG = LoggerFactory.getLogger(CacheManager.class);
 
-    /**
-     * Refreshing the cache comes with some costs, so not every time we visit the CacheManager, but
-     * every 10 visits, refresh the LRU strategy.
-     */
-    public static final int REFRESH_COUNT = 10;
+    private static final CacheCallback NO_CALLBACK = key -> {};
+
+    // Only loading/removal touches this index. Hits use the shared cache directly. The links are
+    // owned by the manager, never by a reader, and empty file entries are removed immediately.
+    private final Map<Path, FilePages> files = new HashMap<>();
 
     private final Cache dataCache;
     private final Cache indexCache;
+    private final long maxDataCacheBytes;
+    private final long maxIndexCacheBytes;
     private final boolean offHeap;
 
     public CacheManager(MemorySize maxMemorySize, double highPriorityPoolRatio) {
@@ -57,11 +71,16 @@
                 MemorySize.ofBytes((long) (maxMemorySize.getBytes() * highPriorityPoolRatio));
         MemorySize dataCacheSize =
                 MemorySize.ofBytes((long) (maxMemorySize.getBytes() * (1 - highPriorityPoolRatio)));
-        this.dataCache = CacheBuilder.newBuilder().maximumWeight(dataCacheSize).build();
+        this.maxDataCacheBytes = dataCacheSize.getBytes();
+        this.maxIndexCacheBytes =
+                highPriorityPoolRatio == 0 ? maxDataCacheBytes : indexCacheSize.getBytes();
+        this.dataCache =
+                CacheBuilder.newBuilder().maximumWeight(dataCacheSize).build(this::onRemoval);
         if (highPriorityPoolRatio == 0) {
             this.indexCache = dataCache;
         } else {
-            this.indexCache = CacheBuilder.newBuilder().maximumWeight(indexCacheSize).build();
+            this.indexCache =
+                    CacheBuilder.newBuilder().maximumWeight(indexCacheSize).build(this::onRemoval);
         }
         this.offHeap = offHeap;
         LOG.info(
@@ -86,20 +105,71 @@
         return indexCache;
     }
 
+    /** Whether a page fits its configured pool; this does not guarantee admission. */
+    public boolean canFitPage(int pageSize, boolean isIndex) {
+        return pageSize <= (isIndex ? maxIndexCacheBytes : maxDataCacheBytes);
+    }
+
+    /** Returns a cached decoded range, or null if a load is needed. */
+    @Nullable
+    public MemorySlice getPageSliceIfPresent(CacheKey key) {
+        CacheValue value = (key.isIndex() ? indexCache : dataCache).getIfPresent(key);
+        return value == null ? null : value.slice;
+    }
+
+    /** Returns a cached page without retaining a loader on the hit path. */
+    @Nullable
+    public MemorySegment getPageIfPresent(CacheKey key) {
+        MemorySlice slice = getPageSliceIfPresent(key);
+        return slice == null ? null : asSegment(slice);
+    }
+
+    public MemorySegment getPage(CacheKey key, CacheReader reader) {
+        return getPage(key, reader, NO_CALLBACK);
+    }
+
     public MemorySegment getPage(CacheKey key, CacheReader reader, CacheCallback callback) {
+        return asSegment(getPageSlice(key, reader, MemorySlice::wrap, callback));
+    }
+
+    private static MemorySegment asSegment(MemorySlice slice) {
+        if (slice.offset() == 0 && slice.length() == slice.segment().size()) {
+            return slice.segment();
+        }
+        // The same key may have been loaded through the slice API. Do not expose framing bytes
+        // outside the decoded range through the original whole-segment API.
+        return MemorySegment.wrap(slice.copyBytes());
+    }
+
+    /** Caches the decoded range, allowing an uncompressed block to share its read buffer. */
+    public MemorySlice getPageSlice(
+            CacheKey key, CacheReader reader, Function<byte[], MemorySlice> decoder) {
+        return getPageSlice(key, reader, decoder, NO_CALLBACK);
+    }
+
+    private MemorySlice getPageSlice(
+            CacheKey key,
+            CacheReader reader,
+            Function<byte[], MemorySlice> decoder,
+            CacheCallback callback) {
         Cache cache = key.isIndex() ? indexCache : dataCache;
         Cache.CacheValue value =
                 cache.get(
                         key,
                         k -> {
                             try {
-                                return new Cache.CacheValue(
-                                        toMemorySegment(reader.read(key)), callback);
+                                Page page =
+                                        new Page(
+                                                key,
+                                                toMemorySlice(decoder.apply(reader.read(key))),
+                                                callback);
+                                register(page);
+                                return page;
                             } catch (IOException e) {
                                 throw new RuntimeException(e);
                             }
                         });
-        return checkNotNull(value, "Cache result for key(%s) is null", key).segment;
+        return checkNotNull(value, "Cache result for key(%s) is null", key).slice;
     }
 
     public boolean contains(CacheKey key) {
@@ -118,13 +188,115 @@
         }
     }
 
-    private MemorySegment toMemorySegment(byte[] bytes) {
-        if (!offHeap) {
-            return MemorySegment.wrap(bytes);
+    /**
+     * Invalidates this file's registered pages, including pages loaded by other readers. Concurrent
+     * reads may repopulate the cache; callers must stop reads before deleting or replacing a file.
+     */
+    public void invalidFile(Path filePath) throws IOException {
+        List<Page> pages = new ArrayList<>();
+        synchronized (files) {
+            FilePages file = files.remove(filePath);
+            if (file == null) {
+                return;
+            }
+            Page page = file.head;
+            while (page != null) {
+                pages.add(page);
+                Page next = page.next;
+                page.owner = null;
+                page.previous = null;
+                page.next = null;
+                page = next;
+            }
+            file.head = null;
         }
-        MemorySegment segment = MemorySegment.allocateOffHeapMemory(bytes.length);
-        segment.put(0, bytes);
-        return segment;
+
+        // Never call the cache while holding the file index lock: loaders and removal listeners
+        // acquire that lock from inside Caffeine. Delete by identity so an old invalidation cannot
+        // delete a replacement loaded for the same key.
+        Throwable collected = null;
+        for (Page page : pages) {
+            try {
+                invalidPage(page.key, page);
+            } catch (Throwable t) {
+                collected = ExceptionUtils.firstOrSuppressed(t, collected);
+            }
+        }
+        if (collected != null) {
+            if (collected instanceof Error) {
+                throw (Error) collected;
+            }
+            if (collected instanceof RuntimeException) {
+                throw (RuntimeException) collected;
+            }
+            throw new IOException(collected);
+        }
+    }
+
+    /** Invalidates a particular page without removing a newer value for the same key. */
+    protected void invalidPage(CacheKey key, CacheValue expected) {
+        (key.isIndex() ? indexCache : dataCache).asMap().remove(key, expected);
+    }
+
+    private void register(Page page) {
+        if (!(page.key instanceof PositionCacheKey)) {
+            return;
+        }
+        Path path = ((PositionCacheKey) page.key).filePath();
+        synchronized (files) {
+            FilePages file = files.computeIfAbsent(path, ignored -> new FilePages(path));
+            page.owner = file;
+            page.next = file.head;
+            if (file.head != null) {
+                file.head.previous = page;
+            }
+            file.head = page;
+        }
+    }
+
+    private void onRemoval(CacheKey key, CacheValue value) {
+        if (value == null) {
+            return;
+        }
+        if (value instanceof Page) {
+            Page page = (Page) value;
+            synchronized (files) {
+                FilePages file = page.owner;
+                if (file != null) {
+                    if (page.previous == null) {
+                        file.head = page.next;
+                    } else {
+                        page.previous.next = page.next;
+                    }
+                    if (page.next != null) {
+                        page.next.previous = page.previous;
+                    }
+                    page.owner = null;
+                    page.previous = null;
+                    page.next = null;
+                    if (file.head == null) {
+                        files.remove(file.path, file);
+                    }
+                }
+            }
+        }
+        value.callback.onRemoval(key);
+    }
+
+    @VisibleForTesting
+    int cachedFileCount() {
+        synchronized (files) {
+            return files.size();
+        }
+    }
+
+    private MemorySlice toMemorySlice(MemorySlice decoded) {
+        if (!offHeap) {
+            return decoded;
+        }
+        MemorySegment segment = MemorySegment.allocateOffHeapMemory(decoded.length());
+        decoded.segment().copyTo(decoded.offset(), segment, 0, decoded.length());
+        return MemorySlice.wrap(segment);
     }
 
     @Override
@@ -135,25 +307,25 @@
         }
     }
 
-    /** The container for the segment. */
-    public static class SegmentContainer {
+    private static class FilePages {
+        private final Path path;
+        private Page head;
 
-        private final MemorySegment segment;
-
-        private int accessCount;
-
-        public SegmentContainer(MemorySegment segment) {
-            this.segment = segment;
-            this.accessCount = 0;
+        private FilePages(Path path) {
+            this.path = path;
         }
+    }
 
-        public MemorySegment access() {
-            this.accessCount++;
-            return segment;
-        }
+    /** Links are guarded by the file index lock; they are detached before notifying callers. */
+    private static class Page extends CacheValue {
+        private final CacheKey key;
+        private FilePages owner;
+        private Page previous;
+        private Page next;
 
-        public int getAccessCount() {
-            return accessCount;
+        private Page(CacheKey key, MemorySlice slice, CacheCallback callback) {
+            super(slice, callback);
+            this.key = key;
         }
     }
 }
diff --git a/paimon-common/src/main/java/org/apache/paimon/io/cache/CaffeineCache.java b/paimon-common/src/main/java/org/apache/paimon/io/cache/CaffeineCache.java
index 3bb47f8..9d86656 100644
--- a/paimon-common/src/main/java/org/apache/paimon/io/cache/CaffeineCache.java
+++ b/paimon-common/src/main/java/org/apache/paimon/io/cache/CaffeineCache.java
@@ -38,7 +38,16 @@
 
     @Nullable
     @Override
+    public CacheValue getIfPresent(CacheKey key) {
+        // Every hit contributes to admission and eviction, including when the hot set changes.
+        return this.cache.getIfPresent(key);
+    }
+
+    @Nullable
+    @Override
     public CacheValue get(CacheKey key, Function<CacheKey, CacheValue> supplier) {
+        // The loading path already follows a failed probe. Let Caffeine handle atomic loading
+        // directly instead of performing another quiet lookup.
         return this.cache.get(key, supplier);
     }
 
diff --git a/paimon-common/src/main/java/org/apache/paimon/sst/BlockCache.java b/paimon-common/src/main/java/org/apache/paimon/sst/BlockCache.java
index 32e341b..0f72283 100644
--- a/paimon-common/src/main/java/org/apache/paimon/sst/BlockCache.java
+++ b/paimon-common/src/main/java/org/apache/paimon/sst/BlockCache.java
@@ -23,17 +23,14 @@
 import org.apache.paimon.fs.VectoredReadable;
 import org.apache.paimon.io.cache.CacheKey;
 import org.apache.paimon.io.cache.CacheManager;
-import org.apache.paimon.io.cache.CacheManager.SegmentContainer;
 import org.apache.paimon.memory.MemorySegment;
-import org.apache.paimon.utils.ExceptionUtils;
+import org.apache.paimon.memory.MemorySlice;
 import org.apache.paimon.utils.IOUtils;
 
+import javax.annotation.Nullable;
+
 import java.io.Closeable;
 import java.io.IOException;
-import java.util.HashSet;
-import java.util.Map;
-import java.util.Set;
-import java.util.concurrent.ConcurrentHashMap;
 import java.util.function.Function;
 
 /** Cache for block reading. */
@@ -42,13 +39,11 @@
     private final Path filePath;
     private final SeekableInputStream input;
     private final CacheManager cacheManager;
-    private final Map<CacheKey, SegmentContainer> blocks;
 
     public BlockCache(Path filePath, SeekableInputStream input, CacheManager cacheManager) {
         this.filePath = filePath;
         this.input = input;
         this.cacheManager = cacheManager;
-        this.blocks = new ConcurrentHashMap<>();
     }
 
     private byte[] readFrom(long offset, int length) throws IOException {
@@ -68,53 +63,41 @@
             long position, int length, Function<byte[], byte[]> decompressFunc, boolean isIndex) {
         CacheKey cacheKey = CacheKey.forPosition(filePath, position, length, isIndex);
 
-        SegmentContainer container = blocks.get(cacheKey);
-        if (container == null || container.getAccessCount() == CacheManager.REFRESH_COUNT) {
-            MemorySegment segment =
-                    cacheManager.getPage(
-                            cacheKey,
-                            key -> {
-                                byte[] bytes = readFrom(position, length);
-                                return decompressFunc.apply(bytes);
-                            },
-                            blocks::remove);
-            container = new SegmentContainer(segment);
-            if (cacheManager.contains(cacheKey)) {
-                blocks.put(cacheKey, container);
-            }
+        // Construct the capturing loader only on misses; hits use the shared cache directly.
+        MemorySegment cached = cacheManager.getPageIfPresent(cacheKey);
+        if (cached != null) {
+            return cached;
         }
-        return container.access();
+        return cacheManager.getPage(
+                cacheKey,
+                key -> {
+                    byte[] bytes = readFrom(position, length);
+                    return decompressFunc.apply(bytes);
+                });
+    }
+
+    /** Returns a decoded block if resident, without reading the file. */
+    @Nullable
+    public MemorySlice getBlockSliceIfPresent(long position, int length, boolean isIndex) {
+        return cacheManager.getPageSliceIfPresent(
+                CacheKey.forPosition(filePath, position, length, isIndex));
+    }
+
+    /**
+     * Reads and caches a decoded block without copying an uncompressed payload out of its buffer.
+     */
+    public MemorySlice getBlockSlice(
+            long position, int length, Function<byte[], MemorySlice> decoder, boolean isIndex) {
+        CacheKey cacheKey = CacheKey.forPosition(filePath, position, length, isIndex);
+        MemorySlice cached = cacheManager.getPageSliceIfPresent(cacheKey);
+        if (cached != null) {
+            return cached;
+        }
+        return cacheManager.getPageSlice(cacheKey, key -> readFrom(position, length), decoder);
     }
 
     @Override
     public void close() throws IOException {
-        // Every page has to be handed back to the shared cache manager. Stopping at the first
-        // failure would leave the rest of this file's pages resident in a cache that is shared
-        // across readers, with nothing left holding a reference to invalidate them later.
-        Set<CacheKey> sets = new HashSet<>(blocks.keySet());
-        Throwable collected = null;
-        for (CacheKey key : sets) {
-            try {
-                cacheManager.invalidPage(key);
-            } catch (Throwable t) {
-                collected = ExceptionUtils.firstOrSuppressed(t, collected);
-            }
-        }
-        if (collected != null) {
-            rethrowAsIOException(collected);
-        }
-    }
-
-    private static void rethrowAsIOException(Throwable failure) throws IOException {
-        if (failure instanceof IOException) {
-            throw (IOException) failure;
-        }
-        if (failure instanceof Error) {
-            throw (Error) failure;
-        }
-        if (failure instanceof RuntimeException) {
-            throw (RuntimeException) failure;
-        }
-        throw new IOException(failure);
+        cacheManager.invalidFile(filePath);
     }
 }
diff --git a/paimon-common/src/main/java/org/apache/paimon/sst/SstFileReader.java b/paimon-common/src/main/java/org/apache/paimon/sst/SstFileReader.java
index a501172..8346464 100644
--- a/paimon-common/src/main/java/org/apache/paimon/sst/SstFileReader.java
+++ b/paimon-common/src/main/java/org/apache/paimon/sst/SstFileReader.java
@@ -20,7 +20,6 @@
 
 import org.apache.paimon.compression.BlockCompressionFactory;
 import org.apache.paimon.compression.BlockDecompressor;
-import org.apache.paimon.memory.MemorySegment;
 import org.apache.paimon.memory.MemorySlice;
 import org.apache.paimon.memory.MemorySliceInput;
 import org.apache.paimon.utils.ExceptionUtils;
@@ -50,6 +49,10 @@
     private final BlockReader indexBlock;
     @Nullable private final FileBasedBloomFilter bloomFilter;
 
+    private boolean hasCompressedBlocks;
+    private boolean loadBloomOnMiss;
+    private long dataBytesWithoutBloom;
+
     public SstFileReader(
             Comparator<MemorySlice> comparator,
             BlockCache blockCache,
@@ -57,8 +60,9 @@
             @Nullable FileBasedBloomFilter bloomFilter) {
         this.comparator = comparator;
         this.blockCache = blockCache;
-        this.indexBlock = readBlock(indexBlockHandle, true);
         this.bloomFilter = bloomFilter;
+        this.loadBloomOnMiss = bloomFilter != null && bloomFilter.isCacheable();
+        this.indexBlock = readBlock(indexBlockHandle, true);
     }
 
     /**
@@ -69,8 +73,19 @@
      */
     @Nullable
     public byte[] lookup(byte[] key) throws IOException {
-        if (bloomFilter != null && !bloomFilter.testHash(MurmurHashUtils.hashBytes(key))) {
-            return null;
+        int hash = 0;
+        Boolean bloomMatch = null;
+        if (bloomFilter != null) {
+            hash = MurmurHashUtils.hashBytes(key);
+            bloomMatch = bloomFilter.testHashIfPresent(hash);
+            if (bloomMatch != null) {
+                // Another reader may have admitted a previously rejected filter.
+                loadBloomOnMiss = true;
+                dataBytesWithoutBloom = 0;
+                if (!bloomMatch) {
+                    return null;
+                }
+            }
         }
 
         MemorySlice keySlice = MemorySlice.wrap(key);
@@ -80,8 +95,48 @@
 
         // if indexIterator does not have a next, it means the key does not exist in this iterator
         if (indexBlockIterator.hasNext()) {
+            BlockHandle handle =
+                    BlockHandle.readBlockHandle(indexBlockIterator.next().getValue().toInput());
+            MemorySlice cachedData = null;
+            boolean bloomProbed = bloomMatch != null;
+            if (bloomFilter != null && bloomMatch == null) {
+                // A missing filter must not cause I/O when the exact data is already resident.
+                cachedData =
+                        blockCache.getBlockSliceIfPresent(
+                                handle.offset(), handle.getFullBlockSize(), false);
+                if (cachedData == null
+                        && (loadBloomOnMiss
+                                || hasCompressedBlocks
+                                || bloomFilter.size() < handle.getFullBlockSize())) {
+                    boolean matches = bloomFilter.testHash(hash);
+                    // Try to warm a filter that fits, but after rejection only reload it when
+                    // its read cost or the saved decompression justifies bypassing the data.
+                    loadBloomOnMiss = bloomFilter.isCached();
+                    dataBytesWithoutBloom = 0;
+                    bloomProbed = true;
+                    if (!matches) {
+                        return null;
+                    }
+                }
+            }
+            BlockReader dataBlock =
+                    cachedData == null
+                            ? readBlock(handle, false)
+                            : createBlockReader(handle, cachedData);
+            if (!bloomProbed
+                    && cachedData == null
+                    && bloomFilter != null
+                    && bloomFilter.isCacheable()) {
+                // Retry after cold data accesses cost as much as a filter read. This allows
+                // recovery from transient rejection without loading Bloom on every miss.
+                dataBytesWithoutBloom += handle.getFullBlockSize();
+                if (dataBytesWithoutBloom >= bloomFilter.size()) {
+                    loadBloomOnMiss = true;
+                    dataBytesWithoutBloom = 0;
+                }
+            }
             // seek the current iterator to the key
-            BlockIterator current = getNextBlock(indexBlockIterator);
+            BlockIterator current = dataBlock.iterator();
             if (current.seekTo(keySlice)) {
                 return current.next().getValue().copyBytes();
             }
@@ -119,41 +174,46 @@
      * @return The reader of the target block.
      */
     private BlockReader readBlock(BlockHandle blockHandle, boolean index) {
-        // read block trailer
-        MemorySegment trailerData =
-                blockCache.getBlock(
-                        blockHandle.offset() + blockHandle.size(),
-                        BlockTrailer.ENCODED_LENGTH,
-                        b -> b,
-                        true);
-        BlockTrailer blockTrailer =
-                BlockTrailer.readBlockTrailer(MemorySlice.wrap(trailerData).toInput());
-
-        MemorySegment unCompressedBlock =
-                blockCache.getBlock(
+        MemorySlice unCompressedBlock =
+                blockCache.getBlockSlice(
                         blockHandle.offset(),
-                        blockHandle.size(),
-                        bytes -> decompressBlock(bytes, blockTrailer),
+                        blockHandle.getFullBlockSize(),
+                        this::decompressBlock,
                         index);
-        return BlockReader.create(MemorySlice.wrap(unCompressedBlock), comparator);
+        return createBlockReader(blockHandle, unCompressedBlock);
     }
 
-    private byte[] decompressBlock(byte[] compressedBytes, BlockTrailer blockTrailer) {
-        MemorySegment compressed = MemorySegment.wrap(compressedBytes);
+    private BlockReader createBlockReader(BlockHandle blockHandle, MemorySlice unCompressedBlock) {
+        // The writer uses compression only when it reduces the payload size. Comparing sizes
+        // also detects compressed blocks obtained from the shared cache, including the index.
+        if (bloomFilter != null && unCompressedBlock.length() != blockHandle.size()) {
+            hasCompressedBlocks = true;
+        }
+        return BlockReader.create(unCompressedBlock, comparator);
+    }
+
+    private MemorySlice decompressBlock(byte[] blockBytes) {
+        MemorySlice fullBlock = MemorySlice.wrap(blockBytes);
+        int blockSize = blockBytes.length - BlockTrailer.ENCODED_LENGTH;
+        MemorySlice compressed = fullBlock.slice(0, blockSize);
+        BlockTrailer blockTrailer =
+                BlockTrailer.readBlockTrailer(
+                        fullBlock.slice(blockSize, BlockTrailer.ENCODED_LENGTH).toInput());
         int crc32cCode = crc32c(compressed, blockTrailer.getCompressionType());
-        checkArgument(
-                blockTrailer.getCrc32c() == crc32cCode,
-                String.format(
-                        "Expected CRC32C(%d) but found CRC32C(%d)",
-                        blockTrailer.getCrc32c(), crc32cCode));
+        if (blockTrailer.getCrc32c() != crc32cCode) {
+            throw new IllegalArgumentException(
+                    String.format(
+                            "Expected CRC32C(%d) but found CRC32C(%d)",
+                            blockTrailer.getCrc32c(), crc32cCode));
+        }
 
         // decompress data
         BlockCompressionFactory compressionFactory =
                 BlockCompressionFactory.create(blockTrailer.getCompressionType());
         if (compressionFactory == null) {
-            return compressedBytes;
+            return compressed;
         } else {
-            MemorySliceInput compressedInput = MemorySlice.wrap(compressed).toInput();
+            MemorySliceInput compressedInput = compressed.toInput();
             byte[] uncompressed = new byte[compressedInput.readVarLenInt()];
             BlockDecompressor decompressor = compressionFactory.getDecompressor();
             int uncompressedLength =
@@ -164,7 +224,7 @@
                             uncompressed,
                             0);
             checkArgument(uncompressedLength == uncompressed.length);
-            return uncompressed;
+            return MemorySlice.wrap(uncompressed);
         }
     }
 
diff --git a/paimon-common/src/main/java/org/apache/paimon/utils/BitSet.java b/paimon-common/src/main/java/org/apache/paimon/utils/BitSet.java
index 020df5e..23fac02 100644
--- a/paimon-common/src/main/java/org/apache/paimon/utils/BitSet.java
+++ b/paimon-common/src/main/java/org/apache/paimon/utils/BitSet.java
@@ -64,6 +64,10 @@
         return this.memorySegment;
     }
 
+    int memoryOffset() {
+        return offset;
+    }
+
     MemorySlice getMemorySlice() {
         return new MemorySlice(
                 checkNotNull(memorySegment, "MemorySegment is not set."), offset, byteLength);
diff --git a/paimon-common/src/main/java/org/apache/paimon/utils/BloomFilter.java b/paimon-common/src/main/java/org/apache/paimon/utils/BloomFilter.java
index 0a96471..c9d0a34 100644
--- a/paimon-common/src/main/java/org/apache/paimon/utils/BloomFilter.java
+++ b/paimon-common/src/main/java/org/apache/paimon/utils/BloomFilter.java
@@ -113,6 +113,15 @@
     }
 
     public boolean testHash(int hash1) {
+        return testHash(hash1, bitSet.getMemorySegment(), bitSet.memoryOffset());
+    }
+
+    /** Probes a cached filter without retaining or mutating its backing segment. */
+    boolean testHash(int hash1, MemorySegment segment) {
+        return testHash(hash1, segment, 0);
+    }
+
+    private boolean testHash(int hash1, MemorySegment segment, int offset) {
         int hash2 = hash1 >>> 16;
 
         for (int i = 1; i <= numHashFunctions; i++) {
@@ -122,7 +131,7 @@
                 combinedHash = ~combinedHash;
             }
             int pos = combinedHash % bitSet.bitSize();
-            if (!bitSet.get(pos)) {
+            if ((segment.get(offset + (pos >>> 3)) & (1 << (pos & 7))) == 0) {
                 return false;
             }
         }
diff --git a/paimon-common/src/main/java/org/apache/paimon/utils/FileBasedBloomFilter.java b/paimon-common/src/main/java/org/apache/paimon/utils/FileBasedBloomFilter.java
index 4e8bc2b..500d018 100644
--- a/paimon-common/src/main/java/org/apache/paimon/utils/FileBasedBloomFilter.java
+++ b/paimon-common/src/main/java/org/apache/paimon/utils/FileBasedBloomFilter.java
@@ -22,7 +22,6 @@
 import org.apache.paimon.fs.Path;
 import org.apache.paimon.fs.SeekableInputStream;
 import org.apache.paimon.fs.VectoredReadable;
-import org.apache.paimon.io.cache.CacheCallback;
 import org.apache.paimon.io.cache.CacheKey;
 import org.apache.paimon.io.cache.CacheKey.PositionCacheKey;
 import org.apache.paimon.io.cache.CacheManager;
@@ -34,7 +33,6 @@
 import java.io.Closeable;
 import java.io.IOException;
 
-import static org.apache.paimon.io.cache.CacheManager.REFRESH_COUNT;
 import static org.apache.paimon.utils.Preconditions.checkArgument;
 
 /** Util to apply a built bloom filter . */
@@ -44,8 +42,7 @@
     private final CacheManager cacheManager;
     private final BloomFilter filter;
     private final PositionCacheKey cacheKey;
-
-    private int accessCount;
+    private final boolean cacheable;
 
     public FileBasedBloomFilter(
             SeekableInputStream input,
@@ -58,8 +55,8 @@
         this.cacheManager = cacheManager;
         checkArgument(expectedEntries >= 0);
         this.filter = new BloomFilter(expectedEntries, readLength);
-        this.accessCount = 0;
         this.cacheKey = CacheKey.forPosition(filePath, readOffset, readLength, true);
+        this.cacheable = cacheManager.canFitPage(readLength, true);
     }
 
     @Nullable
@@ -80,18 +77,34 @@
                 bloomFilterHandle.size());
     }
 
+    /** Whether the complete filter fits the configured index cache budget. */
+    public boolean isCacheable() {
+        return cacheable;
+    }
+
+    /** Whether the filter is currently resident; fitting the budget does not imply admission. */
+    public boolean isCached() {
+        return cacheManager.contains(cacheKey);
+    }
+
+    /** Number of bytes needed to read the complete filter. */
+    public int size() {
+        return cacheKey.length();
+    }
+
+    /** Tests a resident filter, or returns null without reading the file on a cache miss. */
+    @Nullable
+    public Boolean testHashIfPresent(int hash) {
+        MemorySegment segment = cacheManager.getPageIfPresent(cacheKey);
+        return segment == null ? null : filter.testHash(hash, segment);
+    }
+
     public boolean testHash(int hash) {
-        accessCount++;
-        // we should refresh cache in LRU, but we cannot refresh everytime, it is costly.
-        // so we introduce a refresh count to reduce refresh
-        if (accessCount == REFRESH_COUNT || filter.getMemorySegment() == null) {
-            MemorySegment segment =
-                    cacheManager.getPage(
-                            cacheKey, this::readBytes, new BloomFilterCallBack(filter));
-            filter.setMemorySegment(segment, 0);
-            accessCount = 0;
+        MemorySegment segment = cacheManager.getPageIfPresent(cacheKey);
+        if (segment == null) {
+            segment = cacheManager.getPage(cacheKey, this::readBytes);
         }
-        return filter.testHash(hash);
+        return filter.testHash(hash, segment);
     }
 
     private byte[] readBytes(CacheKey k) throws IOException {
@@ -117,19 +130,4 @@
     public void close() throws IOException {
         cacheManager.invalidPage(cacheKey);
     }
-
-    /** Call back for cache manager. */
-    private static class BloomFilterCallBack implements CacheCallback {
-
-        private final BloomFilter bloomFilter;
-
-        private BloomFilterCallBack(BloomFilter bloomFilter) {
-            this.bloomFilter = bloomFilter;
-        }
-
-        @Override
-        public void onRemoval(CacheKey key) {
-            this.bloomFilter.unsetMemorySegment();
-        }
-    }
 }
diff --git a/paimon-common/src/test/java/org/apache/paimon/globalindex/btree/BTreeIndexReaderCloseTest.java b/paimon-common/src/test/java/org/apache/paimon/globalindex/btree/BTreeIndexReaderCloseTest.java
index 27b10a7..e6ea96c 100644
--- a/paimon-common/src/test/java/org/apache/paimon/globalindex/btree/BTreeIndexReaderCloseTest.java
+++ b/paimon-common/src/test/java/org/apache/paimon/globalindex/btree/BTreeIndexReaderCloseTest.java
@@ -29,6 +29,7 @@
 import org.apache.paimon.globalindex.ResultEntry;
 import org.apache.paimon.globalindex.io.GlobalIndexFileReader;
 import org.apache.paimon.globalindex.io.GlobalIndexFileWriter;
+import org.apache.paimon.io.cache.Cache;
 import org.apache.paimon.io.cache.CacheKey;
 import org.apache.paimon.io.cache.CacheManager;
 import org.apache.paimon.options.MemorySize;
@@ -197,7 +198,7 @@
         };
     }
 
-    /** Fails page invalidation, which is what closing the reader's bloom filter does. */
+    /** Fails page invalidation during reader close. */
     private static class FailingCacheManager extends CacheManager {
 
         private boolean failing = false;
@@ -207,6 +208,14 @@
         }
 
         @Override
+        protected void invalidPage(CacheKey key, Cache.CacheValue expected) {
+            if (failing) {
+                throw new RuntimeException("cache is down");
+            }
+            super.invalidPage(key, expected);
+        }
+
+        @Override
         public void invalidPage(CacheKey key) {
             if (failing) {
                 throw new RuntimeException("cache is down");
diff --git a/paimon-common/src/test/java/org/apache/paimon/globalindex/btree/LazyFilteredBTreeIndexReaderTest.java b/paimon-common/src/test/java/org/apache/paimon/globalindex/btree/LazyFilteredBTreeIndexReaderTest.java
index 7828a60..3946f82 100644
--- a/paimon-common/src/test/java/org/apache/paimon/globalindex/btree/LazyFilteredBTreeIndexReaderTest.java
+++ b/paimon-common/src/test/java/org/apache/paimon/globalindex/btree/LazyFilteredBTreeIndexReaderTest.java
@@ -290,11 +290,11 @@
      *
      * <ul>
      *   <li>ConcurrentHashMap reader cache in LazyFilteredBTreeReader
-     *   <li>BlockCache.getBlock() check-then-act race under eviction pressure
+     *   <li>Shared block loading under eviction pressure
      *   <li>CacheManager eviction callbacks racing with concurrent reads
-     *   <li>BTreeIndexReader readLock contention across query types
+     *   <li>Concurrent BTreeIndexReader queries
      *   <li>LazyField initialization race for null bitmaps
-     *   <li>SegmentContainer.accessCount non-atomic increment under contention
+     *   <li>Concurrent access to the shared file cache index
      * </ul>
      */
     @TestTemplate
diff --git a/paimon-common/src/test/java/org/apache/paimon/io/cache/CacheManagerFileTest.java b/paimon-common/src/test/java/org/apache/paimon/io/cache/CacheManagerFileTest.java
new file mode 100644
index 0000000..f4694c9
--- /dev/null
+++ b/paimon-common/src/test/java/org/apache/paimon/io/cache/CacheManagerFileTest.java
@@ -0,0 +1,332 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.paimon.io.cache;
+
+import org.apache.paimon.fs.Path;
+import org.apache.paimon.memory.MemorySegment;
+import org.apache.paimon.memory.MemorySlice;
+import org.apache.paimon.options.MemorySize;
+
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.Timeout;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.ValueSource;
+
+import java.io.IOException;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.concurrent.CountDownLatch;
+import java.util.concurrent.CyclicBarrier;
+import java.util.concurrent.ExecutorService;
+import java.util.concurrent.Executors;
+import java.util.concurrent.Future;
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.atomic.AtomicBoolean;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+
+/** Tests for the shared cache's file index and concurrent file invalidation. */
+@Timeout(30)
+class CacheManagerFileTest {
+
+    @ParameterizedTest
+    @ValueSource(longs = {0, 1})
+    void testRejectedPagesLeaveNoFileIndex(long capacity) throws Exception {
+        try (CacheManager manager = new CacheManager(MemorySize.ofBytes(capacity), 0)) {
+            for (int i = 0; i < 100; i++) {
+                CacheKey key = key(new Path("file-" + i));
+                assertThat(manager.getPage(key, ignored -> new byte[] {1, 2}).getHeapMemory())
+                        .containsExactly(1, 2);
+                assertThat(manager.dataCache().asMap()).isEmpty();
+                assertThat(manager.cachedFileCount()).isZero();
+            }
+        }
+    }
+
+    @ParameterizedTest
+    @ValueSource(booleans = {false, true})
+    void testDecodedRangeChargesItsBackingAllocation(boolean offHeap) throws Exception {
+        byte[] encoded = new byte[] {99, 1, 2, 3, 4, 99};
+        Path file = new Path("decoded-file");
+        CacheKey key = CacheKey.forPosition(file, 0, encoded.length, false);
+        try (CacheManager manager =
+                offHeap
+                        ? CacheManager.createOffHeap(MemorySize.ofBytes(5), 0)
+                        : new CacheManager(MemorySize.ofBytes(5), 0)) {
+            MemorySlice decoded =
+                    manager.getPageSlice(
+                            key, ignored -> encoded, bytes -> MemorySlice.wrap(bytes).slice(1, 4));
+            assertThat(decoded.copyBytes()).containsExactly(1, 2, 3, 4);
+            if (offHeap) {
+                assertThat(decoded.segment().isOffHeap()).isTrue();
+                assertThat(decoded.segment().size()).isEqualTo(4);
+            } else {
+                assertThat(decoded.segment().getHeapMemory()).isSameAs(encoded);
+            }
+            // Heap retains all six bytes; off-heap copies only the four-byte decoded range.
+            assertThat(manager.contains(key)).isEqualTo(offHeap);
+            manager.invalidFile(file);
+            assertThat(manager.cachedFileCount()).isZero();
+        }
+    }
+
+    @ParameterizedTest
+    @ValueSource(booleans = {false, true})
+    void testWholePageAccessHonorsCachedDecodedRange(boolean offHeap) throws Exception {
+        CacheKey key = key(new Path("decoded-file"));
+        try (CacheManager manager =
+                offHeap
+                        ? CacheManager.createOffHeap(MemorySize.ofKibiBytes(64), 0)
+                        : new CacheManager(MemorySize.ofKibiBytes(64), 0)) {
+            manager.getPageSlice(
+                    key,
+                    ignored -> new byte[] {99, 1, 2, 99},
+                    bytes -> MemorySlice.wrap(bytes).slice(1, 2));
+            MemorySegment page =
+                    manager.getPage(
+                            key,
+                            ignored -> {
+                                throw new AssertionError("Decoded page should be cached");
+                            });
+            assertThat(manager.getPageSliceIfPresent(key).copyBytes()).containsExactly(1, 2);
+            MemorySegment cached = manager.getPageIfPresent(key);
+            byte[] cachedBytes = new byte[cached.size()];
+            cached.get(0, cachedBytes);
+            assertThat(cachedBytes).containsExactly(1, 2);
+            assertThat(cached.isOffHeap()).isEqualTo(offHeap);
+            byte[] bytes = new byte[page.size()];
+            page.get(0, bytes);
+            assertThat(bytes).containsExactly(1, 2);
+            assertThat(page.isOffHeap()).isEqualTo(offHeap);
+            manager.invalidPage(key);
+            assertThat(manager.getPageIfPresent(key)).isNull();
+            assertThat(manager.getPageSliceIfPresent(key)).isNull();
+        }
+    }
+
+    @Test
+    void testPresentPagesUseTheirOwnPriorityPool() throws Exception {
+        Path file = new Path("priority-file");
+        CacheKey dataKey = CacheKey.forPosition(file, 0, 2, false);
+        CacheKey indexKey = CacheKey.forPosition(file, 0, 2, true);
+        try (CacheManager manager = new CacheManager(MemorySize.ofKibiBytes(64), 0.5)) {
+            assertThat(manager.getPageIfPresent(dataKey)).isNull();
+            assertThat(manager.getPageSliceIfPresent(indexKey)).isNull();
+            manager.getPage(dataKey, ignored -> new byte[] {1, 2});
+            manager.getPage(indexKey, ignored -> new byte[] {3, 4});
+            assertThat(manager.getPageIfPresent(dataKey).getHeapMemory()).containsExactly(1, 2);
+            assertThat(manager.getPageSliceIfPresent(indexKey).copyBytes()).containsExactly(3, 4);
+            manager.invalidFile(file);
+            assertThat(manager.getPageIfPresent(dataKey)).isNull();
+            assertThat(manager.getPageSliceIfPresent(indexKey)).isNull();
+        }
+    }
+
+    @Test
+    void testEvictionRemovesEmptyFiles() throws Exception {
+        try (CacheManager manager = new CacheManager(MemorySize.ofBytes(16), 0)) {
+            for (int i = 0; i < 100; i++) {
+                manager.getPage(key(new Path("file-" + i)), ignored -> new byte[8]);
+                // Each file has one page. No file index may outlive its cached page.
+                assertThat(manager.cachedFileCount()).isEqualTo(manager.dataCache().asMap().size());
+            }
+            manager.close();
+            assertThat(manager.cachedFileCount()).isZero();
+        }
+    }
+
+    @Test
+    void testRemovingMiddleAndTailPagesKeepsFileIndexAccurate() throws Exception {
+        List<CacheKey> invalidated = new ArrayList<>();
+        Path file = new Path("linked-pages");
+        CacheKey tail = CacheKey.forPosition(file, 0, 2, false);
+        CacheKey middle = CacheKey.forPosition(file, 2, 2, false);
+        CacheKey head = CacheKey.forPosition(file, 4, 2, false);
+        CacheKey other = key(new Path("other-file"));
+        try (CacheManager manager =
+                new CacheManager(MemorySize.ofKibiBytes(64), 0) {
+                    @Override
+                    protected void invalidPage(CacheKey key, Cache.CacheValue expected) {
+                        invalidated.add(key);
+                        super.invalidPage(key, expected);
+                    }
+                }) {
+            for (CacheKey key : new CacheKey[] {tail, middle, head, other}) {
+                manager.getPage(key, ignored -> new byte[2]);
+            }
+            manager.invalidPage(middle);
+            manager.invalidPage(tail);
+            manager.invalidFile(file);
+            assertThat(invalidated).containsExactly(head);
+            assertThat(manager.cachedFileCount()).isOne();
+            assertThat(manager.contains(other)).isTrue();
+            manager.invalidFile(new Path("other-file"));
+            assertThat(manager.cachedFileCount()).isZero();
+        }
+    }
+
+    @Test
+    void testFailedLoadCanRetryWithoutLeavingFileIndex() throws Exception {
+        try (CacheManager manager = new CacheManager(MemorySize.ofKibiBytes(64), 0)) {
+            Path file = new Path("retry-file");
+            CacheKey key = key(file);
+            assertThatThrownBy(
+                            () ->
+                                    manager.getPage(
+                                            key,
+                                            ignored -> {
+                                                throw new IOException("read failed");
+                                            }))
+                    .hasRootCauseMessage("read failed");
+            assertThat(manager.cachedFileCount()).isZero();
+            assertThat(manager.getPage(key, ignored -> new byte[] {7, 8}).getHeapMemory())
+                    .containsExactly(7, 8);
+            manager.invalidFile(file);
+            assertThat(manager.cachedFileCount()).isZero();
+            assertThat(manager.contains(key)).isFalse();
+        }
+    }
+
+    @Test
+    void testOldInvalidationDoesNotRemoveReplacement() throws Exception {
+        Path file = new Path("shared-file");
+        CacheKey key = key(file);
+        CountDownLatch detached = new CountDownLatch(1);
+        CountDownLatch resume = new CountDownLatch(1);
+        AtomicBoolean pause = new AtomicBoolean(true);
+        ExecutorService executor = Executors.newSingleThreadExecutor();
+        try (CacheManager manager =
+                new CacheManager(MemorySize.ofKibiBytes(64), 0) {
+                    @Override
+                    protected void invalidPage(CacheKey k, Cache.CacheValue expected) {
+                        if (pause.compareAndSet(true, false)) {
+                            detached.countDown();
+                            await(resume);
+                        }
+                        super.invalidPage(k, expected);
+                    }
+                }) {
+            manager.getPage(key, ignored -> new byte[] {1, 2});
+            Future<?> invalidation =
+                    executor.submit(
+                            () -> {
+                                manager.invalidFile(file);
+                                return null;
+                            });
+            try {
+                assertThat(detached.await(10, TimeUnit.SECONDS)).isTrue();
+                manager.invalidPage(key);
+                MemorySegment replacement = manager.getPage(key, ignored -> new byte[] {3, 4});
+                resume.countDown();
+                invalidation.get(10, TimeUnit.SECONDS);
+
+                assertThat(
+                                manager.getPage(
+                                        key,
+                                        ignored -> {
+                                            throw new AssertionError("Replacement was invalidated");
+                                        }))
+                        .isSameAs(replacement);
+                assertThat(manager.cachedFileCount()).isOne();
+                manager.invalidFile(file);
+                assertThat(manager.contains(key)).isFalse();
+                assertThat(manager.cachedFileCount()).isZero();
+            } finally {
+                resume.countDown();
+            }
+        } finally {
+            executor.shutdownNow();
+        }
+    }
+
+    @Test
+    void testConcurrentLoadMayRepopulateInvalidatedFile() throws Exception {
+        Path file = new Path("loading-file");
+        CacheKey key = key(file);
+        CountDownLatch loading = new CountDownLatch(1);
+        CountDownLatch resume = new CountDownLatch(1);
+        ExecutorService executor = Executors.newSingleThreadExecutor();
+        try (CacheManager manager = new CacheManager(MemorySize.ofKibiBytes(64), 0)) {
+            Future<MemorySegment> load =
+                    executor.submit(
+                            () ->
+                                    manager.getPage(
+                                            key,
+                                            ignored -> {
+                                                loading.countDown();
+                                                await(resume);
+                                                return new byte[] {5, 6};
+                                            }));
+            try {
+                assertThat(loading.await(10, TimeUnit.SECONDS)).isTrue();
+                manager.invalidFile(file);
+                resume.countDown();
+                assertThat(load.get(10, TimeUnit.SECONDS).getHeapMemory()).containsExactly(5, 6);
+                assertThat(manager.contains(key)).isTrue();
+                manager.invalidFile(file);
+                assertThat(manager.cachedFileCount()).isZero();
+            } finally {
+                resume.countDown();
+            }
+        } finally {
+            executor.shutdownNow();
+        }
+    }
+
+    @Test
+    void testDifferentFilesLoadConcurrently() throws Exception {
+        CyclicBarrier loading = new CyclicBarrier(2);
+        ExecutorService executor = Executors.newFixedThreadPool(2);
+        try (CacheManager manager = new CacheManager(MemorySize.ofKibiBytes(64), 0)) {
+            CacheReader loader =
+                    ignored -> {
+                        try {
+                            loading.await(10, TimeUnit.SECONDS);
+                        } catch (Exception e) {
+                            throw new IOException(e);
+                        }
+                        return new byte[] {9, 10};
+                    };
+            Future<MemorySegment> first =
+                    executor.submit(() -> manager.getPage(key(new Path("first")), loader));
+            Future<MemorySegment> second =
+                    executor.submit(() -> manager.getPage(key(new Path("second")), loader));
+            assertThat(first.get(15, TimeUnit.SECONDS).getHeapMemory()).containsExactly(9, 10);
+            assertThat(second.get(15, TimeUnit.SECONDS).getHeapMemory()).containsExactly(9, 10);
+        } finally {
+            executor.shutdownNow();
+        }
+    }
+
+    private static CacheKey key(Path file) {
+        return CacheKey.forPosition(file, 0, 2, false);
+    }
+
+    private static void await(CountDownLatch latch) {
+        try {
+            if (!latch.await(10, TimeUnit.SECONDS)) {
+                throw new AssertionError("Timed out waiting for cache operation");
+            }
+        } catch (InterruptedException e) {
+            Thread.currentThread().interrupt();
+            throw new RuntimeException(e);
+        }
+    }
+}
diff --git a/paimon-common/src/test/java/org/apache/paimon/io/cache/CacheManagerTest.java b/paimon-common/src/test/java/org/apache/paimon/io/cache/CacheManagerTest.java
index 662978a..1fe1a81 100644
--- a/paimon-common/src/test/java/org/apache/paimon/io/cache/CacheManagerTest.java
+++ b/paimon-common/src/test/java/org/apache/paimon/io/cache/CacheManagerTest.java
@@ -81,9 +81,9 @@
         CacheManager cacheManager =
                 new CacheManager(MemorySize.ofKibiBytes(64), 0) {
                     @Override
-                    public void invalidPage(CacheKey key) {
+                    protected void invalidPage(CacheKey key, Cache.CacheValue expected) {
                         invalidatedPages.incrementAndGet();
-                        super.invalidPage(key);
+                        super.invalidPage(key, expected);
                     }
                 };
         BlockCache blockCache =
@@ -100,6 +100,7 @@
 
         blockCache.close();
         assertThat(invalidatedPages).hasValue(hotPages);
+        assertThat(cacheManager.cachedFileCount()).isZero();
     }
 
     @Test
diff --git a/paimon-common/src/test/java/org/apache/paimon/io/cache/CaffeineCacheTest.java b/paimon-common/src/test/java/org/apache/paimon/io/cache/CaffeineCacheTest.java
new file mode 100644
index 0000000..6464efe
--- /dev/null
+++ b/paimon-common/src/test/java/org/apache/paimon/io/cache/CaffeineCacheTest.java
@@ -0,0 +1,58 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.paimon.io.cache;
+
+import org.apache.paimon.fs.Path;
+import org.apache.paimon.memory.MemorySlice;
+
+import org.apache.paimon.shade.caffeine2.com.github.benmanes.caffeine.cache.Caffeine;
+
+import org.junit.jupiter.api.Test;
+
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.atomic.AtomicLong;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+/** Tests for the Caffeine adapter's non-loading lookup contract. */
+class CaffeineCacheTest {
+
+    @Test
+    void testPresentLookupRefreshesAccessExpiration() {
+        AtomicLong time = new AtomicLong();
+        Cache cache =
+                new CaffeineCache(
+                        Caffeine.newBuilder()
+                                .expireAfterAccess(10, TimeUnit.SECONDS)
+                                .ticker(time::get)
+                                .executor(Runnable::run)
+                                .build());
+        CacheKey key = CacheKey.forPosition(new Path("file"), 0, 2, false);
+        Cache.CacheValue value =
+                new Cache.CacheValue(MemorySlice.wrap(new byte[] {1, 2}), ignored -> {});
+        cache.put(key, value);
+
+        time.set(TimeUnit.SECONDS.toNanos(9));
+        assertThat(cache.getIfPresent(key)).isSameAs(value);
+        time.set(TimeUnit.SECONDS.toNanos(18));
+        assertThat(cache.getIfPresent(key)).isSameAs(value);
+        time.set(TimeUnit.SECONDS.toNanos(29));
+        assertThat(cache.getIfPresent(key)).isNull();
+    }
+}
diff --git a/paimon-common/src/test/java/org/apache/paimon/lookup/sort/SortLookupStoreCloseTest.java b/paimon-common/src/test/java/org/apache/paimon/lookup/sort/SortLookupStoreCloseTest.java
index 06649f6..37e3587 100644
--- a/paimon-common/src/test/java/org/apache/paimon/lookup/sort/SortLookupStoreCloseTest.java
+++ b/paimon-common/src/test/java/org/apache/paimon/lookup/sort/SortLookupStoreCloseTest.java
@@ -24,6 +24,7 @@
 import org.apache.paimon.fs.PositionOutputStream;
 import org.apache.paimon.fs.SeekableInputStream;
 import org.apache.paimon.fs.local.LocalFileIO;
+import org.apache.paimon.io.cache.Cache;
 import org.apache.paimon.io.cache.CacheKey;
 import org.apache.paimon.io.cache.CacheManager;
 import org.apache.paimon.memory.MemorySliceOutput;
@@ -103,7 +104,7 @@
         for (int key = 0; key < 400; key += 40) {
             lookup(reader, key * 2);
         }
-        int cachedPages = cacheManager.pagesTaken.get();
+        int cachedPages = cacheManager.dataCache().asMap().size();
         assertThat(cachedPages).isGreaterThan(1);
 
         cacheManager.failFrom(1);
@@ -164,7 +165,6 @@
     /** A cache manager whose page invalidation can be made to fail. */
     private static class ThrowingCacheManager extends CacheManager {
 
-        private final AtomicInteger pagesTaken = new AtomicInteger();
         private final AtomicInteger invalidated = new AtomicInteger();
         private volatile int failFromCall;
 
@@ -178,19 +178,19 @@
         }
 
         @Override
-        public org.apache.paimon.memory.MemorySegment getPage(
-                CacheKey key,
-                org.apache.paimon.io.cache.CacheReader reader,
-                org.apache.paimon.io.cache.CacheCallback callback) {
-            pagesTaken.incrementAndGet();
-            return super.getPage(key, reader, callback);
+        public void invalidPage(CacheKey key) {
+            super.invalidPage(key);
+            invalidated();
         }
 
         @Override
-        public void invalidPage(CacheKey key) {
-            int call = invalidated.getAndIncrement();
-            super.invalidPage(key);
-            if (call >= failFromCall) {
+        protected void invalidPage(CacheKey key, Cache.CacheValue expected) {
+            super.invalidPage(key, expected);
+            invalidated();
+        }
+
+        private void invalidated() {
+            if (invalidated.getAndIncrement() >= failFromCall) {
                 throw new RuntimeException("invalidPage failed");
             }
         }
diff --git a/paimon-common/src/test/java/org/apache/paimon/sst/BlockCacheTest.java b/paimon-common/src/test/java/org/apache/paimon/sst/BlockCacheTest.java
new file mode 100644
index 0000000..e2221d0
--- /dev/null
+++ b/paimon-common/src/test/java/org/apache/paimon/sst/BlockCacheTest.java
@@ -0,0 +1,189 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.paimon.sst;
+
+import org.apache.paimon.fs.ByteArraySeekableStream;
+import org.apache.paimon.fs.Path;
+import org.apache.paimon.io.cache.CacheKey;
+import org.apache.paimon.io.cache.CacheManager;
+import org.apache.paimon.memory.MemorySegment;
+import org.apache.paimon.memory.MemorySlice;
+import org.apache.paimon.options.MemorySize;
+
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.Timeout;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.ValueSource;
+
+import java.util.concurrent.CountDownLatch;
+import java.util.concurrent.ExecutorService;
+import java.util.concurrent.Executors;
+import java.util.concurrent.Future;
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.atomic.AtomicBoolean;
+import java.util.concurrent.atomic.AtomicInteger;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+/** Tests for shared block ownership and file invalidation. */
+class BlockCacheTest {
+
+    @ParameterizedTest
+    @ValueSource(booleans = {false, true})
+    @Timeout(30)
+    void testConcurrentLoadAfterFastLookupMissIsReused(boolean slice) throws Exception {
+        Path path = new Path("shared-file");
+        CountDownLatch missed = new CountDownLatch(1);
+        CountDownLatch resume = new CountDownLatch(1);
+        AtomicBoolean pause = new AtomicBoolean(true);
+        AtomicInteger loads = new AtomicInteger();
+        ExecutorService executor = Executors.newSingleThreadExecutor();
+        try (CacheManager manager =
+                        new CacheManager(MemorySize.ofKibiBytes(64), 0) {
+                            @Override
+                            public MemorySlice getPageSliceIfPresent(CacheKey key) {
+                                MemorySlice cached = super.getPageSliceIfPresent(key);
+                                if (cached == null && pause.compareAndSet(true, false)) {
+                                    missed.countDown();
+                                    try {
+                                        if (!resume.await(10, TimeUnit.SECONDS)) {
+                                            throw new AssertionError("Timed out after cache miss");
+                                        }
+                                    } catch (InterruptedException e) {
+                                        Thread.currentThread().interrupt();
+                                        throw new RuntimeException(e);
+                                    }
+                                }
+                                return cached;
+                            }
+                        };
+                BlockCache first = new BlockCache(path, input(), manager);
+                BlockCache second = new BlockCache(path, input(), manager)) {
+            Future<MemorySlice> pending = executor.submit(() -> read(first, slice, loads));
+            try {
+                assertThat(missed.await(10, TimeUnit.SECONDS)).isTrue();
+                MemorySlice loaded = read(second, slice, loads);
+                resume.countDown();
+                MemorySlice reused = pending.get(10, TimeUnit.SECONDS);
+                assertThat(reused.copyBytes()).containsExactly(1, 2, 3, 4);
+                assertThat(reused.segment()).isSameAs(loaded.segment());
+                assertThat(loads).hasValue(1);
+            } finally {
+                resume.countDown();
+            }
+        } finally {
+            executor.shutdownNow();
+        }
+    }
+
+    @Test
+    void testSharedReaderReloadsInvalidatedBlock() throws Exception {
+        Path path = new Path("shared-file");
+        AtomicInteger loads = new AtomicInteger();
+        try (CacheManager manager = new CacheManager(MemorySize.ofKibiBytes(64), 0);
+                BlockCache first = new BlockCache(path, input(), manager);
+                BlockCache second = new BlockCache(path, input(), manager)) {
+            MemorySegment original =
+                    first.getBlock(
+                            0,
+                            4,
+                            bytes -> {
+                                loads.incrementAndGet();
+                                return bytes;
+                            },
+                            false);
+            assertThat(
+                            second.getBlock(
+                                    0,
+                                    4,
+                                    bytes -> {
+                                        loads.incrementAndGet();
+                                        return bytes;
+                                    },
+                                    false))
+                    .isSameAs(original);
+            manager.invalidPage(CacheKey.forPosition(path, 0, 4, false));
+
+            MemorySegment reloaded =
+                    second.getBlock(
+                            0,
+                            4,
+                            bytes -> {
+                                loads.incrementAndGet();
+                                return bytes;
+                            },
+                            false);
+            assertThat(reloaded.getHeapMemory()).containsExactly(1, 2, 3, 4);
+            assertThat(loads).hasValue(2);
+        }
+    }
+
+    @Test
+    void testCloseInvalidatesAllReadersPagesForFile() throws Exception {
+        Path path = new Path("shared-file");
+        try (CacheManager manager = new CacheManager(MemorySize.ofKibiBytes(64), 0.5);
+                BlockCache first = new BlockCache(path, input(), manager);
+                BlockCache second = new BlockCache(path, input(), manager);
+                BlockCache other = new BlockCache(new Path("other-file"), input(), manager)) {
+            first.getBlock(0, 4, bytes -> bytes, false);
+            second.getBlock(4, 4, bytes -> bytes, true);
+            MemorySegment otherBlock = other.getBlock(0, 4, bytes -> bytes, false);
+
+            first.close();
+
+            assertThat(manager.contains(CacheKey.forPosition(path, 0, 4, false))).isFalse();
+            assertThat(manager.contains(CacheKey.forPosition(path, 4, 4, true))).isFalse();
+            assertThat(
+                            other.getBlock(
+                                    0,
+                                    4,
+                                    bytes -> {
+                                        throw new AssertionError("Unexpected reload");
+                                    },
+                                    false))
+                    .isSameAs(otherBlock);
+        }
+    }
+
+    private static MemorySlice read(BlockCache cache, boolean slice, AtomicInteger loads) {
+        if (slice) {
+            return cache.getBlockSlice(
+                    0,
+                    4,
+                    bytes -> {
+                        loads.incrementAndGet();
+                        return MemorySlice.wrap(bytes);
+                    },
+                    false);
+        }
+        return MemorySlice.wrap(
+                cache.getBlock(
+                        0,
+                        4,
+                        bytes -> {
+                            loads.incrementAndGet();
+                            return bytes;
+                        },
+                        false));
+    }
+
+    private static ByteArraySeekableStream input() {
+        return new ByteArraySeekableStream(new byte[] {1, 2, 3, 4, 5, 6, 7, 8});
+    }
+}
diff --git a/paimon-common/src/test/java/org/apache/paimon/sst/SstFileReaderCacheTest.java b/paimon-common/src/test/java/org/apache/paimon/sst/SstFileReaderCacheTest.java
new file mode 100644
index 0000000..86e2298
--- /dev/null
+++ b/paimon-common/src/test/java/org/apache/paimon/sst/SstFileReaderCacheTest.java
@@ -0,0 +1,498 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.paimon.sst;
+
+import org.apache.paimon.compression.BlockCompressionFactory;
+import org.apache.paimon.compression.BlockCompressionType;
+import org.apache.paimon.fs.ByteArraySeekableStream;
+import org.apache.paimon.fs.Path;
+import org.apache.paimon.fs.PositionOutputStream;
+import org.apache.paimon.io.cache.CacheKey;
+import org.apache.paimon.io.cache.CacheManager;
+import org.apache.paimon.memory.MemorySegment;
+import org.apache.paimon.options.MemorySize;
+import org.apache.paimon.utils.BloomFilter;
+import org.apache.paimon.utils.FileBasedBloomFilter;
+import org.apache.paimon.utils.MurmurHashUtils;
+
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.CsvSource;
+import org.junit.jupiter.params.provider.ValueSource;
+
+import java.io.ByteArrayOutputStream;
+import java.io.IOException;
+import java.util.Arrays;
+import java.util.Comparator;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+
+/** Tests the combined block/trailer cache without changing the SST format. */
+class SstFileReaderCacheTest {
+
+    @ParameterizedTest
+    @CsvSource({"NONE,false", "NONE,true", "LZ4,false", "LZ4,true"})
+    void testRejectedBloomUsesCachedData(String compression, boolean offHeap) throws Exception {
+        BloomFixture fixture = new BloomFixture(compression, 16 * 1024);
+        CountingInput input = new CountingInput(fixture.bytes);
+        try (CacheManager manager =
+                        offHeap
+                                ? CacheManager.createOffHeap(MemorySize.ofMebiBytes(1), 0.5)
+                                : new CacheManager(MemorySize.ofMebiBytes(1), 0.5);
+                SstFileReader reader = fixture.reader(input, manager)) {
+            assertThat(reader.lookup(new byte[] {2})).containsExactly(fixture.value);
+            assertThat(manager.contains(fixture.bloomKey())).isFalse();
+            assertThat(manager.dataCache().asMap()).hasSize(1);
+
+            input.reads = 0;
+            assertThat(reader.lookup(new byte[] {4})).containsExactly(fixture.value);
+            assertThat(reader.lookup(new byte[] {1})).isNull();
+            assertThat(input.reads).isZero();
+
+            // Another reader can admit the filter later. Use it without loading the data block.
+            manager.invalidPage(fixture.dataKey());
+            manager.getPage(
+                    fixture.bloomKey(),
+                    ignored ->
+                            Arrays.copyOfRange(
+                                    fixture.bytes,
+                                    (int) fixture.bloom.offset(),
+                                    (int) fixture.bloom.offset() + fixture.bloom.size()));
+            assertThat(reader.lookup(new byte[] {1})).isNull();
+            assertThat(input.reads).isZero();
+            assertThat(manager.dataCache().asMap()).isEmpty();
+        }
+    }
+
+    @ParameterizedTest
+    @ValueSource(booleans = {false, true})
+    void testRejectedBloomCanRecoverInTheSameReader(boolean offHeap) throws Exception {
+        BloomFixture fixture = new BloomFixture("NONE", 16 * 1024);
+        CountingInput input = new CountingInput(fixture.bytes);
+        try (CacheManager manager =
+                        offHeap
+                                ? CacheManager.createOffHeap(MemorySize.ofMebiBytes(1), 0.5)
+                                : new CacheManager(MemorySize.ofMebiBytes(1), 0.5);
+                SstFileReader reader = fixture.reader(input, manager)) {
+            assertThat(reader.lookup(new byte[] {2})).containsExactly(fixture.value);
+            assertThat(manager.contains(fixture.bloomKey())).isFalse();
+            fixture.rejectBloom = false;
+            input.bytesRead = 0;
+            for (int i = 0; i < 64 && !manager.contains(fixture.bloomKey()); i++) {
+                manager.invalidPage(fixture.dataKey());
+                assertThat(reader.lookup(new byte[] {2})).containsExactly(fixture.value);
+            }
+            assertThat(manager.contains(fixture.bloomKey())).isTrue();
+            assertThat(input.bytesRead)
+                    .isLessThanOrEqualTo(2 * fixture.bloom.size() + 2 * fixture.dataSize);
+            manager.invalidPage(fixture.dataKey());
+            input.reads = 0;
+            assertThat(reader.lookup(new byte[] {1})).isNull();
+            assertThat(input.reads).isZero();
+        }
+    }
+
+    @ParameterizedTest
+    @CsvSource({
+        "NONE,false,16384",
+        "NONE,true,16384",
+        "NONE,false,256",
+        "NONE,true,256",
+        "LZ4,false,16384",
+        "LZ4,true,16384"
+    })
+    void testRejectedBloomReadCost(String compression, boolean offHeap, int bloomSize)
+            throws Exception {
+        BloomFixture fixture = new BloomFixture(compression, bloomSize);
+        CountingInput input = new CountingInput(fixture.bytes);
+        try (CacheManager manager =
+                        offHeap
+                                ? CacheManager.createOffHeap(MemorySize.ofMebiBytes(1), 0.5)
+                                : new CacheManager(MemorySize.ofMebiBytes(1), 0.5);
+                SstFileReader reader = fixture.reader(input, manager)) {
+            assertThat(reader.lookup(new byte[] {2})).containsExactly(fixture.value);
+            manager.invalidPage(fixture.dataKey());
+            input.reads = 0;
+            input.bytesRead = 0;
+            assertThat(reader.lookup(new byte[] {1})).isNull();
+            assertThat(input.reads).isOne();
+            boolean cheaperBloom = bloomSize < fixture.dataSize || "LZ4".equals(compression);
+            assertThat(input.bytesRead).isEqualTo(cheaperBloom ? bloomSize : fixture.dataSize);
+
+            input.reads = 0;
+            assertThat(reader.lookup(new byte[] {127})).isNull();
+            assertThat(input.reads).isZero();
+        }
+    }
+
+    @ParameterizedTest
+    @ValueSource(booleans = {false, true})
+    void testCompressedIndexKeepsUncacheableBloomOnReopen(boolean offHeap) throws Exception {
+        ByteArrayOutputStream bytes = new ByteArrayOutputStream();
+        PositionOutputStream out = output(bytes);
+        SstFileWriter writer =
+                new SstFileWriter(
+                        out, 256, null, BlockCompressionFactory.create(BlockCompressionType.LZ4));
+        byte[] value = new byte[64];
+        for (int i = 0; i < 1000; i++) {
+            MemorySegment key = MemorySegment.allocateHeapMemory(32);
+            key.putInt(0, 2 * i);
+            writer.put(key.getHeapMemory(), value);
+        }
+        writer.flush();
+        BloomFilter bloom = new BloomFilter(1000, 128 * 1024);
+        bloom.setMemorySegment(MemorySegment.allocateHeapMemory(128 * 1024), 0);
+        for (int i = 0; i < 1000; i++) {
+            MemorySegment key = MemorySegment.allocateHeapMemory(32);
+            key.putInt(0, 2 * i);
+            bloom.addHash(MurmurHashUtils.hashBytes(key.getHeapMemory()));
+        }
+        BloomFilterHandle bloomHandle = bloom.write(out);
+        BlockHandle index = writer.writeIndexBlock();
+        byte[] file = bytes.toByteArray();
+        assertThat(file[(int) index.offset() + index.size()]).isNotZero();
+        MemorySegment missing = MemorySegment.allocateHeapMemory(32);
+        missing.putInt(0, 1);
+        assertThat(bloom.testHash(MurmurHashUtils.hashBytes(missing.getHeapMemory()))).isFalse();
+        Path path = new Path("compressed-index-bloom");
+        try (CacheManager manager =
+                offHeap
+                        ? CacheManager.createOffHeap(MemorySize.ofKibiBytes(64), 0.5)
+                        : new CacheManager(MemorySize.ofKibiBytes(64), 0.5)) {
+            // Keep both readers open so the second borrows the already decoded index page.
+            CountingInput first = new CountingInput(file);
+            CountingInput second = new CountingInput(file);
+            try (SstFileReader reader =
+                            new SstFileReader(
+                                    Comparator.comparingInt(slice -> slice.readInt(0)),
+                                    new BlockCache(path, first, manager),
+                                    index,
+                                    FileBasedBloomFilter.create(
+                                            first, path, manager, bloomHandle));
+                    SstFileReader reopened =
+                            new SstFileReader(
+                                    Comparator.comparingInt(slice -> slice.readInt(0)),
+                                    new BlockCache(path, second, manager),
+                                    index,
+                                    FileBasedBloomFilter.create(
+                                            second, path, manager, bloomHandle))) {
+                assertThat(first.reads).isOne();
+                assertThat(second.reads).isZero();
+                first.reads = 0;
+                first.bytesRead = 0;
+                assertThat(reader.lookup(missing.getHeapMemory())).isNull();
+                assertThat(reopened.lookup(missing.getHeapMemory())).isNull();
+                assertThat(first.reads).isOne();
+                assertThat(second.reads).isOne();
+                assertThat(first.bytesRead).isEqualTo(bloomHandle.size());
+                assertThat(second.bytesRead).isEqualTo(bloomHandle.size());
+                assertThat(manager.dataCache().asMap()).isEmpty();
+            }
+        }
+    }
+
+    @ParameterizedTest
+    @CsvSource({
+        "NONE,false,-1,0",
+        "NONE,false,0,0",
+        "NONE,false,1,0",
+        "NONE,true,1,0",
+        "LZ4,false,1,0",
+        "LZ4,true,1,0",
+        "LZ4,false,4096,0",
+        "LZ4,true,4096,0",
+        "NONE,false,1,1",
+        "NONE,true,1,1",
+        "NONE,false,1,2"
+    })
+    void testUncacheableBloomReadCost(
+            String compression, boolean offHeap, int sizeDelta, int cacheBudget) throws Exception {
+        ByteArrayOutputStream bytes = new ByteArrayOutputStream();
+        PositionOutputStream out = output(bytes);
+        SstFileWriter writer =
+                new SstFileWriter(
+                        out,
+                        64 * 1024,
+                        null,
+                        BlockCompressionFactory.create(BlockCompressionType.valueOf(compression)));
+        byte[] value = new byte[64];
+        Arrays.fill(value, (byte) 42);
+        for (int i = 0; i < 16; i++) {
+            writer.put(new byte[] {(byte) (2 * i)}, value);
+        }
+        writer.flush();
+        int dataBlockSize = bytes.size();
+        BloomFilter bloom = new BloomFilter(1000, dataBlockSize + sizeDelta);
+        bloom.setMemorySegment(MemorySegment.allocateHeapMemory(dataBlockSize + sizeDelta), 0);
+        for (int i = 0; i < 16; i++) {
+            bloom.addHash(MurmurHashUtils.hashBytes(new byte[] {(byte) (2 * i)}));
+        }
+        byte[] missing = new byte[] {1};
+        while (missing[0] < 31 && bloom.testHash(MurmurHashUtils.hashBytes(missing))) {
+            missing[0] += 2;
+        }
+        assertThat(bloom.testHash(MurmurHashUtils.hashBytes(missing))).isFalse();
+        BloomFilterHandle bloomHandle = bloom.write(out);
+        BlockHandle index = writer.writeIndexBlock();
+        CountingInput input = new CountingInput(bytes.toByteArray());
+        Path path = new Path("bloom-read-cost");
+        boolean cached = cacheBudget == 1;
+        // Budget 2 admits the data block but cannot fit the larger Bloom in the index pool.
+        MemorySize capacity =
+                cached
+                        ? MemorySize.ofMebiBytes(1)
+                        : MemorySize.ofBytes(cacheBudget == 2 ? 2L * (bloomHandle.size() - 1) : 0);
+        try (CacheManager manager =
+                        offHeap
+                                ? CacheManager.createOffHeap(capacity, 0.5)
+                                : new CacheManager(capacity, 0.5);
+                SstFileReader reader =
+                        new SstFileReader(
+                                Comparator.comparingInt(slice -> slice.readByte(0)),
+                                new BlockCache(path, input, manager),
+                                index,
+                                FileBasedBloomFilter.create(input, path, manager, bloomHandle))) {
+            input.reads = 0;
+            input.bytesRead = 0;
+            assertThat(reader.lookup(new byte[] {2})).containsExactly(value);
+            boolean probeBloom = cached || sizeDelta < 0;
+            assertThat(input.reads).isEqualTo(probeBloom ? 2 : 1);
+            assertThat(input.bytesRead)
+                    .isEqualTo(dataBlockSize + (probeBloom ? bloomHandle.size() : 0));
+
+            // Force the absent-key lookup to choose between reading Bloom and reading data.
+            manager.invalidPage(CacheKey.forPosition(path, 0, dataBlockSize, false));
+            input.reads = 0;
+            input.bytesRead = 0;
+            assertThat(reader.lookup(missing)).isNull();
+            assertThat(input.reads).isEqualTo(cached ? 0 : 1);
+            // A compressed block can be much larger to decode than its serialized size.
+            boolean probeOnMissing = probeBloom || "LZ4".equals(compression);
+            assertThat(input.bytesRead)
+                    .isEqualTo(cached ? 0 : probeOnMissing ? bloomHandle.size() : dataBlockSize);
+            input.reads = 0;
+            assertThat(reader.lookup(new byte[] {127})).isNull();
+            assertThat(input.reads).isZero();
+            if (cacheBudget == 0) {
+                assertThat(manager.dataCache().asMap()).isEmpty();
+                assertThat(manager.indexCache().asMap()).isEmpty();
+            } else if (cacheBudget == 2) {
+                assertThat(manager.dataCache().asMap()).hasSize(1);
+                assertThat(
+                                manager.contains(
+                                        CacheKey.forPosition(
+                                                path,
+                                                bloomHandle.offset(),
+                                                bloomHandle.size(),
+                                                true)))
+                        .isFalse();
+            }
+        }
+    }
+
+    @ParameterizedTest
+    @CsvSource({"NONE,false", "NONE,true", "LZ4,false", "LZ4,true"})
+    void testOneReadAndCacheEntryPerBlock(String compression, boolean offHeap) throws Exception {
+        Fixture fixture = new Fixture(compression);
+        CountingInput input = new CountingInput(fixture.bytes);
+        try (CacheManager manager =
+                        offHeap
+                                ? CacheManager.createOffHeap(MemorySize.ofMebiBytes(1), 0.5)
+                                : new CacheManager(MemorySize.ofMebiBytes(1), 0.5);
+                SstFileReader reader = fixture.reader(input, manager)) {
+            assertThat(input.reads).isOne();
+            assertThat(reader.lookup(new byte[] {2})).containsExactly(fixture.value);
+            assertThat(input.reads).isEqualTo(2);
+            assertThat(manager.dataCache().asMap()).hasSize(1);
+            assertThat(manager.indexCache().asMap()).hasSize(1);
+            assertThat(reader.lookup(new byte[] {3})).containsExactly(fixture.value);
+            assertThat(input.reads).isEqualTo(2);
+        }
+    }
+
+    @ParameterizedTest
+    @ValueSource(strings = {"NONE", "LZ4"})
+    void testCorruptBlockIsRejectedAndCanBeRetried(String compression) throws Exception {
+        Fixture fixture = new Fixture(compression);
+        CountingInput input = new CountingInput(fixture.bytes);
+        try (CacheManager manager = new CacheManager(MemorySize.ofMebiBytes(1), 0.5);
+                SstFileReader reader = fixture.reader(input, manager)) {
+            fixture.bytes[0] ^= 1;
+            assertThatThrownBy(() -> reader.lookup(new byte[] {2}))
+                    .isInstanceOf(IllegalArgumentException.class)
+                    .hasMessageContaining("Expected CRC32C");
+            assertThat(manager.dataCache().asMap()).isEmpty();
+            fixture.bytes[0] ^= 1;
+            assertThat(reader.lookup(new byte[] {2})).containsExactly(fixture.value);
+            assertThat(manager.dataCache().asMap()).hasSize(1);
+        }
+    }
+
+    private static class BloomFixture {
+        private final Path path = new Path("rejected-bloom");
+        private final byte[] value = new byte[64];
+        private final byte[] bytes;
+        private final int dataSize;
+        private final BlockHandle index;
+        private final BloomFilterHandle bloom;
+        private boolean rejectBloom = true;
+
+        private BloomFixture(String compression, int bloomSize) throws IOException {
+            Arrays.fill(value, (byte) 42);
+            ByteArrayOutputStream bytes = new ByteArrayOutputStream();
+            PositionOutputStream out = output(bytes);
+            SstFileWriter writer =
+                    new SstFileWriter(
+                            out,
+                            64 * 1024,
+                            null,
+                            BlockCompressionFactory.create(
+                                    BlockCompressionType.valueOf(compression)));
+            BloomFilter filter = new BloomFilter(16, bloomSize);
+            filter.setMemorySegment(MemorySegment.allocateHeapMemory(bloomSize), 0);
+            for (int i = 0; i < 16; i++) {
+                byte[] key = new byte[] {(byte) (2 * i)};
+                writer.put(key, value);
+                filter.addHash(MurmurHashUtils.hashBytes(key));
+            }
+            writer.flush();
+            this.dataSize = bytes.size();
+            this.bloom = filter.write(out);
+            this.index = writer.writeIndexBlock();
+            this.bytes = bytes.toByteArray();
+            assertThat(filter.testHash(MurmurHashUtils.hashBytes(new byte[] {1}))).isFalse();
+            assertThat(this.bytes[dataSize - BlockTrailer.ENCODED_LENGTH] == 0)
+                    .isEqualTo("NONE".equals(compression));
+        }
+
+        private CacheKey bloomKey() {
+            return CacheKey.forPosition(path, bloom.offset(), bloom.size(), true);
+        }
+
+        private CacheKey dataKey() {
+            return CacheKey.forPosition(path, 0, dataSize, false);
+        }
+
+        private SstFileReader reader(CountingInput input, CacheManager manager) {
+            FileBasedBloomFilter filter =
+                    new FileBasedBloomFilter(
+                            input, path, manager, 16, bloom.offset(), bloom.size()) {
+                        @Override
+                        public boolean testHash(int hash) {
+                            boolean result = super.testHash(hash);
+                            // Admission is optional even when the page fits. Force this legal
+                            // outcome without depending on Caffeine's frequency sketch or timing.
+                            if (rejectBloom) {
+                                manager.invalidPage(bloomKey());
+                            }
+                            return result;
+                        }
+                    };
+            assertThat(filter.isCacheable()).isTrue();
+            return new SstFileReader(
+                    Comparator.comparingInt(slice -> slice.readByte(0)),
+                    new BlockCache(path, input, manager),
+                    index,
+                    filter);
+        }
+    }
+
+    private static class Fixture {
+        private final byte[] value = new byte[2048];
+        private final byte[] bytes;
+        private final BlockHandle index;
+
+        private Fixture(String compression) throws IOException {
+            Arrays.fill(value, (byte) 42);
+            ByteArrayOutputStream bytes = new ByteArrayOutputStream();
+            PositionOutputStream out = output(bytes);
+            SstFileWriter writer =
+                    new SstFileWriter(
+                            out,
+                            64 * 1024,
+                            null,
+                            BlockCompressionFactory.create(
+                                    BlockCompressionType.valueOf(compression)));
+            for (int i = 1; i <= 3; i++) {
+                writer.put(new byte[] {(byte) i}, value);
+            }
+            writer.flush();
+            this.index = writer.writeIndexBlock();
+            this.bytes = bytes.toByteArray();
+            // Verify the fixture really exercises compressed data, not the writer's raw fallback.
+            int compressionId = this.bytes[(int) index.offset() - BlockTrailer.ENCODED_LENGTH];
+            assertThat(compressionId == 0).isEqualTo("NONE".equals(compression));
+        }
+
+        private SstFileReader reader(CountingInput input, CacheManager manager) {
+            return new SstFileReader(
+                    Comparator.comparingInt(slice -> slice.readByte(0)),
+                    new BlockCache(new Path("sst-file"), input, manager),
+                    index,
+                    null);
+        }
+    }
+
+    private static PositionOutputStream output(ByteArrayOutputStream bytes) {
+        return new PositionOutputStream() {
+            @Override
+            public void close() {}
+
+            @Override
+            public void flush() {}
+
+            @Override
+            public long getPos() {
+                return bytes.size();
+            }
+
+            @Override
+            public void write(int b) {
+                bytes.write(b);
+            }
+
+            @Override
+            public void write(byte[] b) {
+                bytes.write(b, 0, b.length);
+            }
+
+            @Override
+            public void write(byte[] b, int offset, int length) {
+                bytes.write(b, offset, length);
+            }
+        };
+    }
+
+    private static class CountingInput extends ByteArraySeekableStream {
+        private int reads;
+        private int bytesRead;
+
+        private CountingInput(byte[] bytes) {
+            super(bytes);
+        }
+
+        @Override
+        public int read(byte[] b, int offset, int length) throws IOException {
+            reads++;
+            int read = super.read(b, offset, length);
+            bytesRead += Math.max(read, 0);
+            return read;
+        }
+    }
+}
diff --git a/paimon-common/src/test/java/org/apache/paimon/utils/BloomFilterTest.java b/paimon-common/src/test/java/org/apache/paimon/utils/BloomFilterTest.java
index e4c3058..379b807 100644
--- a/paimon-common/src/test/java/org/apache/paimon/utils/BloomFilterTest.java
+++ b/paimon-common/src/test/java/org/apache/paimon/utils/BloomFilterTest.java
@@ -22,6 +22,8 @@
 
 import org.assertj.core.api.Assertions;
 import org.junit.jupiter.api.Test;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.ValueSource;
 
 import java.util.Arrays;
 
@@ -155,6 +157,34 @@
                         i -> Assertions.assertThat(filter.testHash(Integer.hashCode(i))).isFalse());
     }
 
+    @ParameterizedTest
+    @ValueSource(booleans = {false, true})
+    void testProbeWithOffsetAndBorrowedSegment(boolean offHeap) {
+        MemorySegment original =
+                offHeap
+                        ? MemorySegment.allocateOffHeapMemory(75)
+                        : MemorySegment.allocateHeapMemory(75);
+        for (int i = 0; i < 11; i++) {
+            original.put(i, (byte) 0xff);
+        }
+        BloomFilter filter = new BloomFilter(10, 64);
+        filter.setMemorySegment(original, 11);
+        filter.addHash(42);
+        Assertions.assertThat(filter.testHash(42)).isTrue();
+        Assertions.assertThat(filter.testHash(43)).isFalse();
+
+        byte[] bytes = new byte[64];
+        original.get(11, bytes);
+        MemorySegment borrowed =
+                offHeap
+                        ? MemorySegment.allocateOffHeapMemory(64)
+                        : MemorySegment.allocateHeapMemory(64);
+        borrowed.put(0, bytes);
+        Assertions.assertThat(filter.testHash(42, borrowed)).isTrue();
+        Assertions.assertThat(filter.testHash(43, borrowed)).isFalse();
+        Assertions.assertThat(filter.getMemorySegment()).isSameAs(original);
+    }
+
     private static int numHashFunctions(long expectedEntries, double fpp) {
         return buildFilter(BloomFilter.fixedBuilder(expectedEntries, fpp)).numHashFunctions();
     }
diff --git a/paimon-common/src/test/java/org/apache/paimon/utils/FileBasedBloomFilterTest.java b/paimon-common/src/test/java/org/apache/paimon/utils/FileBasedBloomFilterTest.java
index 9d19d81..f25e51f 100644
--- a/paimon-common/src/test/java/org/apache/paimon/utils/FileBasedBloomFilterTest.java
+++ b/paimon-common/src/test/java/org/apache/paimon/utils/FileBasedBloomFilterTest.java
@@ -18,6 +18,7 @@
 
 package org.apache.paimon.utils;
 
+import org.apache.paimon.fs.ByteArraySeekableStream;
 import org.apache.paimon.fs.FileIO;
 import org.apache.paimon.fs.local.LocalFileIO;
 import org.apache.paimon.io.cache.CacheManager;
@@ -27,6 +28,8 @@
 import org.assertj.core.api.Assertions;
 import org.junit.jupiter.api.Test;
 import org.junit.jupiter.api.io.TempDir;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.CsvSource;
 
 import java.io.File;
 import java.io.IOException;
@@ -35,12 +38,76 @@
 import java.nio.file.StandardOpenOption;
 import java.util.Arrays;
 import java.util.UUID;
+import java.util.concurrent.atomic.AtomicInteger;
 
 /** Test for {@link FileBasedBloomFilter}. */
 public class FileBasedBloomFilterTest {
 
     @TempDir Path tempDir;
 
+    @ParameterizedTest
+    @CsvSource({
+        "64,0,64,true",
+        "64,0.5,32,true",
+        "64,0.5,33,false",
+        "64,0.75,32,true",
+        "0,0,1,false"
+    })
+    void testFilterFitsItsActualIndexPool(long capacity, double indexRatio, int size, boolean fits)
+            throws Exception {
+        byte[] bytes = new byte[size];
+        Arrays.fill(bytes, (byte) 0xff);
+        AtomicInteger reads = new AtomicInteger();
+        try (CacheManager manager = new CacheManager(MemorySize.ofBytes(capacity), indexRatio);
+                FileBasedBloomFilter filter =
+                        new FileBasedBloomFilter(
+                                countingInput(bytes, reads),
+                                new org.apache.paimon.fs.Path("pool-boundary"),
+                                manager,
+                                10,
+                                0,
+                                size)) {
+            Assertions.assertThat(filter.isCacheable()).isEqualTo(fits);
+            Assertions.assertThat(filter.testHash(123)).isTrue();
+            Assertions.assertThat(filter.testHash(123)).isTrue();
+            Assertions.assertThat(reads).hasValue(fits ? 1 : 2);
+        }
+    }
+
+    @Test
+    void testResidentProbeDoesNotReadOrRetainAnEvictedFilter() throws Exception {
+        MemorySegment segment = MemorySegment.allocateHeapMemory(64);
+        BloomFilter bloom = new BloomFilter(10, segment.size());
+        bloom.setMemorySegment(segment, 0);
+        bloom.addHash(123);
+        Assertions.assertThat(bloom.testHash(124)).isFalse();
+        AtomicInteger reads = new AtomicInteger();
+        org.apache.paimon.fs.Path path = new org.apache.paimon.fs.Path("resident-filter");
+        try (CacheManager manager = new CacheManager(MemorySize.ofKibiBytes(64), 0.5);
+                FileBasedBloomFilter filter =
+                        new FileBasedBloomFilter(
+                                countingInput(segment.getHeapMemory(), reads),
+                                path,
+                                manager,
+                                10,
+                                0,
+                                segment.size())) {
+            Assertions.assertThat(filter.testHashIfPresent(123)).isNull();
+            Assertions.assertThat(reads).hasValue(0);
+            Assertions.assertThat(filter.testHash(123)).isTrue();
+            Assertions.assertThat(filter.testHashIfPresent(123)).isTrue();
+            Assertions.assertThat(filter.testHashIfPresent(124)).isFalse();
+            Assertions.assertThat(reads).hasValue(1);
+
+            manager.invalidFile(path);
+            Assertions.assertThat(filter.testHashIfPresent(123)).isNull();
+            Assertions.assertThat(reads).hasValue(1);
+            Assertions.assertThat(filter.bloomFilter().getMemorySegment()).isNull();
+            Assertions.assertThat(filter.testHash(123)).isTrue();
+            Assertions.assertThat(reads).hasValue(2);
+        }
+    }
+
     @Test
     public void testProbe() throws IOException {
         MemorySegment segment = MemorySegment.wrap(new byte[1000]);
@@ -65,6 +132,40 @@
         Assertions.assertThat(filter.bloomFilter().getMemorySegment()).isNull();
     }
 
+    @Test
+    void testSharedFilterReloadsAfterFileInvalidation() throws Exception {
+        byte[] bytes = new byte[64];
+        Arrays.fill(bytes, (byte) 0xff);
+        AtomicInteger reads = new AtomicInteger();
+        org.apache.paimon.fs.Path path = new org.apache.paimon.fs.Path("shared-filter");
+        try (CacheManager manager = new CacheManager(MemorySize.ofKibiBytes(64), 0.5)) {
+            FileBasedBloomFilter first =
+                    new FileBasedBloomFilter(
+                            countingInput(bytes, reads), path, manager, 10, 0, bytes.length);
+            FileBasedBloomFilter second =
+                    new FileBasedBloomFilter(
+                            countingInput(bytes, reads), path, manager, 10, 0, bytes.length);
+            Assertions.assertThat(first.testHash(123)).isTrue();
+            Assertions.assertThat(second.testHash(123)).isTrue();
+            Assertions.assertThat(reads).hasValue(1);
+            manager.invalidFile(path);
+            Assertions.assertThat(second.testHash(123)).isTrue();
+            Assertions.assertThat(reads).hasValue(2);
+            Assertions.assertThat(first.bloomFilter().getMemorySegment()).isNull();
+            Assertions.assertThat(second.bloomFilter().getMemorySegment()).isNull();
+        }
+    }
+
+    private static ByteArraySeekableStream countingInput(byte[] bytes, AtomicInteger reads) {
+        return new ByteArraySeekableStream(bytes) {
+            @Override
+            public int read(byte[] b, int offset, int length) throws IOException {
+                reads.incrementAndGet();
+                return super.read(b, offset, length);
+            }
+        };
+    }
+
     private File writeFile(byte[] bytes) throws IOException {
         File file = new File(tempDir.toFile(), UUID.randomUUID().toString());
         if (!file.createNewFile()) {