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

JingsongLi pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/paimon.git


The following commit(s) were added to refs/heads/master by this push:
     new a1fcfe1095 [common] Restore local cache after FileIO deserialization 
(#8926)
a1fcfe1095 is described below

commit a1fcfe10959cfbf6c54b8493365bd36746f3edc7
Author: shyjsarah <[email protected]>
AuthorDate: Thu Jul 30 19:17:57 2026 +0800

    [common] Restore local cache after FileIO deserialization (#8926)
---
 .../org/apache/paimon/fs/cache/CachingFileIO.java  | 249 ++++++++++++++++++--
 .../fs/cache/CachingSeekableInputStream.java       |  19 +-
 .../apache/paimon/fs/cache/LocalCacheManager.java  |   3 +
 .../paimon/fs/cache/LocalDiskCacheManager.java     |  30 ++-
 .../paimon/fs/cache/LocalMemoryCacheManager.java   |  15 ++
 .../apache/paimon/fs/cache/CachingFileIOTest.java  | 256 ++++++++++++++++++++-
 .../paimon/fs/cache/LocalDiskCacheManagerTest.java |  23 ++
 7 files changed, 559 insertions(+), 36 deletions(-)

diff --git 
a/paimon-common/src/main/java/org/apache/paimon/fs/cache/CachingFileIO.java 
b/paimon-common/src/main/java/org/apache/paimon/fs/cache/CachingFileIO.java
index 14b5c39abf..28d5276db4 100644
--- a/paimon-common/src/main/java/org/apache/paimon/fs/cache/CachingFileIO.java
+++ b/paimon-common/src/main/java/org/apache/paimon/fs/cache/CachingFileIO.java
@@ -33,31 +33,57 @@ import org.apache.paimon.utils.FileType;
 import javax.annotation.Nullable;
 
 import java.io.IOException;
+import java.io.ObjectInputStream;
+import java.io.Serializable;
 import java.time.Duration;
 import java.util.EnumSet;
+import java.util.HashMap;
 import java.util.Map;
+import java.util.Objects;
 import java.util.Set;
+import java.util.UUID;
+import java.util.concurrent.ConcurrentHashMap;
 
 /**
  * A {@link FileIO} wrapper that caches reads at block granularity.
  *
  * <p>Only file types in the whitelist are cached. Others are read directly 
from the delegate.
  *
- * <p>After deserialization, the cache is null and reads fall through to the 
delegate directly.
+ * <p>After deserialization, copies of the same catalog-created wrapper lazily 
reuse a JVM-local
+ * cache manager.
  */
 public class CachingFileIO implements FileIO {
 
     private static final long serialVersionUID = 1L;
 
+    // Copies of one serialized wrapper share a JVM-local limit instead of 
allocating one per task.
+    private static final Map<LocalCacheConfiguration, SharedCacheManager>
+            DESERIALIZED_CACHE_MANAGERS = new ConcurrentHashMap<>();
+
     private final FileIO delegate;
     private final Set<FileType> whitelist;
+    @Nullable private final LocalCacheConfiguration cacheConfiguration;
+    private String cacheNamespace;
 
     private transient volatile LocalCacheManager cache;
+    private transient volatile SharedCacheManager sharedCacheManager;
+    private transient volatile boolean closed;
 
     public CachingFileIO(FileIO delegate, LocalCacheManager cache, 
Set<FileType> whitelist) {
+        this(delegate, cache, whitelist, null, UUID.randomUUID().toString());
+    }
+
+    private CachingFileIO(
+            FileIO delegate,
+            LocalCacheManager cache,
+            Set<FileType> whitelist,
+            @Nullable LocalCacheConfiguration cacheConfiguration,
+            String cacheNamespace) {
         this.delegate = delegate;
         this.cache = cache;
         this.whitelist = EnumSet.copyOf(whitelist);
+        this.cacheConfiguration = cacheConfiguration;
+        this.cacheNamespace = cacheNamespace;
     }
 
     /**
@@ -82,7 +108,13 @@ public class CachingFileIO implements FileIO {
         if (whitelist.isEmpty()) {
             return fileIO;
         }
-        return new CachingFileIO(fileIO, cache, whitelist);
+        String cacheNamespace = UUID.randomUUID().toString();
+        return new CachingFileIO(
+                fileIO,
+                cache,
+                whitelist,
+                LocalCacheConfiguration.from(context, cacheNamespace),
+                cacheNamespace);
     }
 
     /**
@@ -91,31 +123,26 @@ public class CachingFileIO implements FileIO {
      */
     @Nullable
     public static LocalCacheManager createCacheManager(CatalogContext context) 
{
-        Options options = context.options();
-        if (!options.get(CatalogOptions.LOCAL_CACHE_ENABLED)) {
-            return null;
-        }
-
-        MemorySize maxSizeOpt = 
options.get(CatalogOptions.LOCAL_CACHE_MAX_SIZE);
-        long maxSize = maxSizeOpt == null ? Long.MAX_VALUE : 
maxSizeOpt.getBytes();
-        int blockSize = (int) 
options.get(CatalogOptions.LOCAL_CACHE_BLOCK_SIZE).getBytes();
-
-        String cacheDir = options.get(CatalogOptions.LOCAL_CACHE_DIR);
-        if (cacheDir != null) {
-            return new LocalDiskCacheManager(cacheDir, maxSize, blockSize);
-        } else {
-            return new LocalMemoryCacheManager(maxSize, blockSize);
-        }
+        LocalCacheConfiguration configuration = 
LocalCacheConfiguration.from(context, "");
+        return configuration == null ? null : 
configuration.createCacheManager();
     }
 
     @Override
     public SeekableInputStream newInputStream(Path path) throws IOException {
-        LocalCacheManager c = cache;
         FileType fileType = FileType.classify(path);
-        if (c == null || !whitelist.contains(fileType) || 
FileType.isMutable(path)) {
+        if (!whitelist.contains(fileType) || FileType.isMutable(path)) {
+            return delegate.newInputStream(path);
+        }
+        LocalCacheManager c = getOrCreateCacheManager();
+        if (c == null) {
             return delegate.newInputStream(path);
         }
-        return new CachingSeekableInputStream(delegate, path, c);
+        if (c instanceof LocalDiskCacheManager) {
+            FileStatus status = delegate.getFileStatus(path);
+            return new CachingSeekableInputStream(
+                    delegate, path, c, diskCacheKey(path, status), 
status.getLen());
+        }
+        return new CachingSeekableInputStream(delegate, path, c, 
cacheNamespace + ":" + path, -1);
     }
 
     @Override
@@ -176,6 +203,186 @@ public class CachingFileIO implements FileIO {
 
     @Override
     public void close() throws IOException {
-        delegate.close();
+        SharedCacheManager shared;
+        synchronized (this) {
+            closed = true;
+            shared = sharedCacheManager;
+            sharedCacheManager = null;
+            if (shared != null) {
+                cache = null;
+            }
+        }
+
+        try {
+            delegate.close();
+        } finally {
+            if (shared != null) {
+                releaseCacheManager(cacheConfiguration, shared);
+            }
+        }
+    }
+
+    @Nullable
+    private LocalCacheManager getOrCreateCacheManager() {
+        if (closed) {
+            return null;
+        }
+        LocalCacheManager current = cache;
+        if (current == null && cacheConfiguration != null) {
+            synchronized (this) {
+                if (closed) {
+                    return null;
+                }
+                current = cache;
+                if (current == null) {
+                    SharedCacheManager shared = sharedCacheManager;
+                    if (shared == null) {
+                        shared = acquireCacheManager(cacheConfiguration);
+                        sharedCacheManager = shared;
+                    }
+                    current = shared.getOrCreate(cacheConfiguration);
+                    cache = current;
+                }
+            }
+        }
+        return current;
+    }
+
+    private static SharedCacheManager acquireCacheManager(
+            LocalCacheConfiguration cacheConfiguration) {
+        return DESERIALIZED_CACHE_MANAGERS.compute(
+                cacheConfiguration,
+                (ignored, existing) -> {
+                    SharedCacheManager shared =
+                            existing == null ? new SharedCacheManager() : 
existing;
+                    shared.retain(cacheConfiguration.cacheNamespace);
+                    return shared;
+                });
+    }
+
+    private static void releaseCacheManager(
+            @Nullable LocalCacheConfiguration cacheConfiguration, 
SharedCacheManager shared) {
+        if (cacheConfiguration == null) {
+            return;
+        }
+        DESERIALIZED_CACHE_MANAGERS.computeIfPresent(
+                cacheConfiguration,
+                (ignored, existing) -> {
+                    if (existing != shared) {
+                        return existing;
+                    }
+                    return existing.release(cacheConfiguration.cacheNamespace) 
? null : existing;
+                });
+    }
+
+    private static String diskCacheKey(Path path, FileStatus status) {
+        return path + "\0" + status.getLen() + "\0" + 
status.getModificationTime();
+    }
+
+    private void readObject(ObjectInputStream input) throws IOException, 
ClassNotFoundException {
+        input.defaultReadObject();
+        if (cacheConfiguration != null) {
+            sharedCacheManager = acquireCacheManager(cacheConfiguration);
+        }
+    }
+
+    private static class SharedCacheManager {
+
+        @Nullable private LocalCacheManager cacheManager;
+        private final Map<String, Integer> namespaceReferences = new 
HashMap<>();
+        private int references;
+
+        private synchronized void retain(String cacheNamespace) {
+            references++;
+            namespaceReferences.merge(cacheNamespace, 1, Integer::sum);
+        }
+
+        private synchronized LocalCacheManager getOrCreate(
+                LocalCacheConfiguration cacheConfiguration) {
+            if (cacheManager == null) {
+                cacheManager = cacheConfiguration.createCacheManager();
+            }
+            return cacheManager;
+        }
+
+        private synchronized boolean release(String cacheNamespace) {
+            if (references <= 0) {
+                throw new IllegalStateException("Cache manager has already 
been released.");
+            }
+            Integer namespaceCount = namespaceReferences.get(cacheNamespace);
+            if (namespaceCount == null) {
+                throw new IllegalStateException("Cache namespace has already 
been released.");
+            }
+            if (namespaceCount == 1) {
+                namespaceReferences.remove(cacheNamespace);
+                if (cacheManager != null) {
+                    cacheManager.invalidate(cacheNamespace + ":");
+                }
+            } else {
+                namespaceReferences.put(cacheNamespace, namespaceCount - 1);
+            }
+            return --references == 0;
+        }
+    }
+
+    private static class LocalCacheConfiguration implements Serializable {
+
+        private static final long serialVersionUID = 1L;
+
+        @Nullable private final String cacheDir;
+        private final long maxSize;
+        private final int blockSize;
+        private final String cacheNamespace;
+
+        private LocalCacheConfiguration(
+                @Nullable String cacheDir, long maxSize, int blockSize, String 
cacheNamespace) {
+            this.cacheDir = cacheDir;
+            this.maxSize = maxSize;
+            this.blockSize = blockSize;
+            this.cacheNamespace = cacheNamespace;
+        }
+
+        @Nullable
+        private static LocalCacheConfiguration from(CatalogContext context, 
String cacheNamespace) {
+            Options options = context.options();
+            if (!options.get(CatalogOptions.LOCAL_CACHE_ENABLED)) {
+                return null;
+            }
+
+            MemorySize maxSizeOption = 
options.get(CatalogOptions.LOCAL_CACHE_MAX_SIZE);
+            long maxSize = maxSizeOption == null ? Long.MAX_VALUE : 
maxSizeOption.getBytes();
+            int blockSize = (int) 
options.get(CatalogOptions.LOCAL_CACHE_BLOCK_SIZE).getBytes();
+            return new LocalCacheConfiguration(
+                    options.get(CatalogOptions.LOCAL_CACHE_DIR),
+                    maxSize,
+                    blockSize,
+                    cacheNamespace);
+        }
+
+        private LocalCacheManager createCacheManager() {
+            if (cacheDir == null) {
+                return new LocalMemoryCacheManager(maxSize, blockSize);
+            }
+            return new LocalDiskCacheManager(cacheDir, maxSize, blockSize);
+        }
+
+        @Override
+        public boolean equals(Object object) {
+            if (this == object) {
+                return true;
+            }
+            if (!(object instanceof LocalCacheConfiguration)) {
+                return false;
+            }
+            LocalCacheConfiguration that = (LocalCacheConfiguration) object;
+            return maxSize == that.maxSize
+                    && blockSize == that.blockSize
+                    && Objects.equals(cacheDir, that.cacheDir);
+        }
+
+        @Override
+        public int hashCode() {
+            return Objects.hash(cacheDir, maxSize, blockSize);
+        }
     }
 }
diff --git 
a/paimon-common/src/main/java/org/apache/paimon/fs/cache/CachingSeekableInputStream.java
 
b/paimon-common/src/main/java/org/apache/paimon/fs/cache/CachingSeekableInputStream.java
index 024bf1e409..e3f990beb6 100644
--- 
a/paimon-common/src/main/java/org/apache/paimon/fs/cache/CachingSeekableInputStream.java
+++ 
b/paimon-common/src/main/java/org/apache/paimon/fs/cache/CachingSeekableInputStream.java
@@ -33,26 +33,33 @@ public class CachingSeekableInputStream extends 
SeekableInputStream implements V
     private final FileIO fileIO;
     private final Path path;
     private final LocalCacheManager cache;
+    private final String cacheKey;
     private long pos;
-    private long fileSize = -1;
+    private long fileSize;
     @Nullable private SeekableInputStream remoteStream;
 
     public CachingSeekableInputStream(FileIO fileIO, Path path, 
LocalCacheManager cache) {
+        this(fileIO, path, cache, path.toString(), -1);
+    }
+
+    CachingSeekableInputStream(
+            FileIO fileIO, Path path, LocalCacheManager cache, String 
cacheKey, long fileSize) {
         this.fileIO = fileIO;
         this.path = path;
         this.cache = cache;
+        this.cacheKey = cacheKey;
+        this.fileSize = fileSize;
         this.pos = 0;
     }
 
     private long fileSize() throws IOException {
         if (fileSize == -1) {
-            String pathStr = path.toString();
-            long cached = cache.getFileSize(pathStr);
+            long cached = cache.getFileSize(cacheKey);
             if (cached >= 0) {
                 fileSize = cached;
             } else {
                 fileSize = fileIO.getFileStatus(path).getLen();
-                cache.putFileSize(pathStr, fileSize);
+                cache.putFileSize(cacheKey, fileSize);
             }
         }
         return fileSize;
@@ -142,7 +149,7 @@ public class CachingSeekableInputStream extends 
SeekableInputStream implements V
     }
 
     private byte[] readBlock(int blockIndex) throws IOException {
-        byte[] cached = cache.getBlock(path.toString(), blockIndex);
+        byte[] cached = cache.getBlock(cacheKey, blockIndex);
         if (cached != null) {
             return cached;
         }
@@ -153,7 +160,7 @@ public class CachingSeekableInputStream extends 
SeekableInputStream implements V
 
         byte[] data = readRemote(offset, readSize);
 
-        cache.putBlock(path.toString(), blockIndex, data);
+        cache.putBlock(cacheKey, blockIndex, data);
         return data;
     }
 
diff --git 
a/paimon-common/src/main/java/org/apache/paimon/fs/cache/LocalCacheManager.java 
b/paimon-common/src/main/java/org/apache/paimon/fs/cache/LocalCacheManager.java
index 4929bf5cd8..7b56078232 100644
--- 
a/paimon-common/src/main/java/org/apache/paimon/fs/cache/LocalCacheManager.java
+++ 
b/paimon-common/src/main/java/org/apache/paimon/fs/cache/LocalCacheManager.java
@@ -34,4 +34,7 @@ public interface LocalCacheManager {
     long getFileSize(String filePath);
 
     void putFileSize(String filePath, long size);
+
+    /** Invalidates all entries whose file path starts with the given prefix. 
*/
+    default void invalidate(String filePathPrefix) {}
 }
diff --git 
a/paimon-common/src/main/java/org/apache/paimon/fs/cache/LocalDiskCacheManager.java
 
b/paimon-common/src/main/java/org/apache/paimon/fs/cache/LocalDiskCacheManager.java
index 6dfc710e3b..d020feac42 100644
--- 
a/paimon-common/src/main/java/org/apache/paimon/fs/cache/LocalDiskCacheManager.java
+++ 
b/paimon-common/src/main/java/org/apache/paimon/fs/cache/LocalDiskCacheManager.java
@@ -44,6 +44,7 @@ import java.util.concurrent.ConcurrentHashMap;
 public class LocalDiskCacheManager implements LocalCacheManager {
 
     private static final Logger LOG = 
LoggerFactory.getLogger(LocalDiskCacheManager.class);
+    private static final String CACHE_FORMAT_VERSION = "v2";
 
     private final File cacheDir;
     private final long maxSizeBytes;
@@ -56,7 +57,8 @@ public class LocalDiskCacheManager implements 
LocalCacheManager {
     private long currentSize;
 
     public LocalDiskCacheManager(String cacheDir, long maxSizeBytes, int 
blockSize) {
-        this.cacheDir = new File(cacheDir);
+        this.cacheDir =
+                new File(new File(cacheDir, CACHE_FORMAT_VERSION), 
"block-size-" + blockSize);
         this.maxSizeBytes = maxSizeBytes;
         this.blockSize = blockSize;
         this.entryIndex = new LinkedHashMap<>(64, 0.75f, true);
@@ -71,15 +73,27 @@ public class LocalDiskCacheManager implements 
LocalCacheManager {
     public byte[] getBlock(String filePath, int blockIndex) {
         File path = cachePath(filePath, blockIndex);
         String cacheKey = path.getPath();
+        boolean needEvict = false;
         synchronized (lock) {
             if (!entryIndex.containsKey(cacheKey)) {
-                return null;
+                if (!path.isFile()) {
+                    return null;
+                }
+                long size = path.length();
+                entryIndex.put(cacheKey, size);
+                currentSize += size;
+                needEvict = maxSizeBytes < Long.MAX_VALUE && currentSize > 
maxSizeBytes;
+            } else {
+                // access to update LRU order
+                entryIndex.get(cacheKey);
             }
-            // access to update LRU order
-            entryIndex.get(cacheKey);
         }
         try {
-            return Files.readAllBytes(path.toPath());
+            byte[] data = Files.readAllBytes(path.toPath());
+            if (needEvict) {
+                evict();
+            }
+            return data;
         } catch (IOException e) {
             LOG.debug("Failed to read cache block: {}", path, e);
             synchronized (lock) {
@@ -125,8 +139,8 @@ public class LocalDiskCacheManager implements 
LocalCacheManager {
 
         boolean needEvict = false;
         synchronized (lock) {
-            entryIndex.put(cacheKey, (long) data.length);
-            currentSize += data.length;
+            Long previousSize = entryIndex.put(cacheKey, (long) data.length);
+            currentSize += data.length - (previousSize == null ? 0 : 
previousSize);
             needEvict = maxSizeBytes < Long.MAX_VALUE && currentSize > 
maxSizeBytes;
         }
         if (needEvict) {
@@ -187,7 +201,7 @@ public class LocalDiskCacheManager implements 
LocalCacheManager {
     }
 
     private File cachePath(String filePath, int blockIndex) {
-        String key = filePath + ":" + blockIndex;
+        String key = blockSize + ":" + filePath + ":" + blockIndex;
         String hex = sha256Hex(key);
         String prefix = hex.substring(0, 2);
         return new File(new File(cacheDir, prefix), hex);
diff --git 
a/paimon-common/src/main/java/org/apache/paimon/fs/cache/LocalMemoryCacheManager.java
 
b/paimon-common/src/main/java/org/apache/paimon/fs/cache/LocalMemoryCacheManager.java
index 3e7baab21e..e92cb88412 100644
--- 
a/paimon-common/src/main/java/org/apache/paimon/fs/cache/LocalMemoryCacheManager.java
+++ 
b/paimon-common/src/main/java/org/apache/paimon/fs/cache/LocalMemoryCacheManager.java
@@ -89,6 +89,21 @@ public class LocalMemoryCacheManager implements 
LocalCacheManager {
         fileSizeCache.put(filePath, size);
     }
 
+    @Override
+    public void invalidate(String filePathPrefix) {
+        synchronized (lock) {
+            Iterator<Map.Entry<BlockKey, byte[]>> iterator = 
cache.entrySet().iterator();
+            while (iterator.hasNext()) {
+                Map.Entry<BlockKey, byte[]> entry = iterator.next();
+                if (entry.getKey().filePath.startsWith(filePathPrefix)) {
+                    currentSize -= entry.getValue().length;
+                    iterator.remove();
+                }
+            }
+        }
+        fileSizeCache.keySet().removeIf(filePath -> 
filePath.startsWith(filePathPrefix));
+    }
+
     private static class BlockKey {
         final String filePath;
         final int blockIndex;
diff --git 
a/paimon-common/src/test/java/org/apache/paimon/fs/cache/CachingFileIOTest.java 
b/paimon-common/src/test/java/org/apache/paimon/fs/cache/CachingFileIOTest.java
index 688ae36fc1..dec0d7b7d4 100644
--- 
a/paimon-common/src/test/java/org/apache/paimon/fs/cache/CachingFileIOTest.java
+++ 
b/paimon-common/src/test/java/org/apache/paimon/fs/cache/CachingFileIOTest.java
@@ -25,18 +25,29 @@ import org.apache.paimon.fs.FileStatus;
 import org.apache.paimon.fs.Path;
 import org.apache.paimon.fs.PositionOutputStream;
 import org.apache.paimon.fs.SeekableInputStream;
+import org.apache.paimon.options.MemorySize;
+import org.apache.paimon.options.Options;
 import org.apache.paimon.utils.FileType;
+import org.apache.paimon.utils.InstantiationUtil;
 
 import org.junit.jupiter.api.BeforeEach;
 import org.junit.jupiter.api.Test;
 import org.junit.jupiter.api.io.TempDir;
 
+import javax.annotation.Nullable;
+
 import java.io.IOException;
 import java.time.Duration;
 import java.util.EnumSet;
 import java.util.HashMap;
 import java.util.Map;
+import java.util.concurrent.ConcurrentHashMap;
+import java.util.concurrent.atomic.AtomicInteger;
 
+import static org.apache.paimon.options.CatalogOptions.LOCAL_CACHE_DIR;
+import static org.apache.paimon.options.CatalogOptions.LOCAL_CACHE_ENABLED;
+import static org.apache.paimon.options.CatalogOptions.LOCAL_CACHE_MAX_SIZE;
+import static org.apache.paimon.options.CatalogOptions.LOCAL_CACHE_WHITELIST;
 import static org.assertj.core.api.Assertions.assertThat;
 import static org.mockito.Mockito.mock;
 import static org.mockito.Mockito.verify;
@@ -52,6 +63,7 @@ class CachingFileIOTest {
     @BeforeEach
     void setUp() {
         cacheDir = tempDir.resolve("cache").toString();
+        MockFileIO.resetGlobalInputStreamCalls();
     }
 
     @Test
@@ -103,7 +115,7 @@ class CachingFileIOTest {
             assertThat(result).isEqualTo(data);
         }
 
-        assertThat(delegate.getFileStatusCallCount("snapshot-1")).isEqualTo(1);
+        assertThat(delegate.getFileStatusCallCount("snapshot-1")).isEqualTo(2);
     }
 
     @Test
@@ -139,6 +151,233 @@ class CachingFileIOTest {
         }
     }
 
+    @Test
+    void testCacheIsRecreatedAfterSerialization() throws Exception {
+        byte[] data = "index data".getBytes();
+        MockFileIO delegate = new MockFileIO();
+        delegate.addFile("global-index-uuid.index", data);
+
+        CatalogContext context = localCacheContext();
+        CachingFileIO cachingIO = newCatalogCachingFileIO(delegate, context);
+
+        CachingFileIO restored = InstantiationUtil.clone(cachingIO);
+
+        try (SeekableInputStream stream =
+                restored.newInputStream(new Path("global-index-uuid.index"))) {
+            assertThat(stream).isInstanceOf(CachingSeekableInputStream.class);
+            assertThat(readAll(stream, data.length)).isEqualTo(data);
+        }
+        restored.close();
+    }
+
+    @Test
+    void testDeserializedCopiesOfSameWrapperShareCacheInSameJvm() throws 
Exception {
+        byte[] data = "shared index data".getBytes();
+        String fileName = "global-index-shared-uuid.index";
+
+        CatalogContext context = localCacheContext();
+
+        MockFileIO delegate = new MockFileIO();
+        delegate.addFile(fileName, data);
+        CachingFileIO original = newCatalogCachingFileIO(delegate, context);
+        CachingFileIO first = InstantiationUtil.clone(original);
+        CachingFileIO second = InstantiationUtil.clone(original);
+
+        try (SeekableInputStream stream = first.newInputStream(new 
Path(fileName))) {
+            assertThat(readAll(stream, data.length)).isEqualTo(data);
+        }
+        
assertThat(MockFileIO.globalInputStreamCallCount(fileName)).isEqualTo(1);
+
+        try (SeekableInputStream stream = second.newInputStream(new 
Path(fileName))) {
+            assertThat(readAll(stream, data.length)).isEqualTo(data);
+        }
+        
assertThat(MockFileIO.globalInputStreamCallCount(fileName)).isEqualTo(1);
+
+        first.close();
+        second.close();
+    }
+
+    @Test
+    void testDeserializedWrappersFromDifferentManagersDoNotShareCache() throws 
Exception {
+        byte[] firstData = "first catalog data".getBytes();
+        byte[] secondData = "other catalog data".getBytes();
+        String fileName = "global-index-isolated-uuid.index";
+
+        CatalogContext context = localCacheContext();
+
+        MockFileIO firstDelegate = new MockFileIO();
+        firstDelegate.addFile(fileName, firstData);
+        CachingFileIO first =
+                InstantiationUtil.clone(newCatalogCachingFileIO(firstDelegate, 
context));
+
+        MockFileIO secondDelegate = new MockFileIO();
+        secondDelegate.addFile(fileName, secondData);
+        CachingFileIO second =
+                
InstantiationUtil.clone(newCatalogCachingFileIO(secondDelegate, context));
+
+        try (SeekableInputStream stream = first.newInputStream(new 
Path(fileName))) {
+            assertThat(readAll(stream, firstData.length)).isEqualTo(firstData);
+        }
+
+        try (SeekableInputStream stream = second.newInputStream(new 
Path(fileName))) {
+            assertThat(readAll(stream, 
secondData.length)).isEqualTo(secondData);
+        }
+        
assertThat(MockFileIO.globalInputStreamCallCount(fileName)).isEqualTo(2);
+
+        first.close();
+        second.close();
+    }
+
+    @Test
+    void testWrappersWithSameManagerUseDifferentSecurityScopes() throws 
Exception {
+        byte[] firstData = "first security data".getBytes();
+        byte[] secondData = "other security data".getBytes();
+        String fileName = "global-index-security-scope-uuid.index";
+        CatalogContext context = localCacheContext();
+        LocalCacheManager cache = CachingFileIO.createCacheManager(context);
+
+        MockFileIO firstDelegate = new MockFileIO();
+        firstDelegate.addFile(fileName, firstData);
+        CachingFileIO first =
+                InstantiationUtil.clone(
+                        (CachingFileIO)
+                                CachingFileIO.wrapWithCachingIfNeeded(
+                                        firstDelegate, context, cache));
+
+        MockFileIO secondDelegate = new MockFileIO();
+        secondDelegate.addFile(fileName, secondData);
+        CachingFileIO second =
+                InstantiationUtil.clone(
+                        (CachingFileIO)
+                                CachingFileIO.wrapWithCachingIfNeeded(
+                                        secondDelegate, context, cache));
+
+        try (SeekableInputStream stream = first.newInputStream(new 
Path(fileName))) {
+            assertThat(readAll(stream, firstData.length)).isEqualTo(firstData);
+        }
+        try (SeekableInputStream stream = second.newInputStream(new 
Path(fileName))) {
+            assertThat(readAll(stream, 
secondData.length)).isEqualTo(secondData);
+        }
+
+        first.close();
+        second.close();
+    }
+
+    @Test
+    void testDeserializedWrappersShareConfiguredMemoryLimit() throws Exception 
{
+        byte[] firstData = "first-cache-block".getBytes();
+        byte[] secondData = "second-cache-data".getBytes();
+        String firstFile = "global-index-limit-first.index";
+        String secondFile = "global-index-limit-second.index";
+        CatalogContext context = localCacheContext(firstData.length);
+
+        MockFileIO firstDelegate = new MockFileIO();
+        firstDelegate.addFile(firstFile, firstData);
+        CachingFileIO first =
+                InstantiationUtil.clone(newCatalogCachingFileIO(firstDelegate, 
context));
+
+        MockFileIO secondDelegate = new MockFileIO();
+        secondDelegate.addFile(secondFile, secondData);
+        CachingFileIO second =
+                
InstantiationUtil.clone(newCatalogCachingFileIO(secondDelegate, context));
+
+        try (SeekableInputStream stream = first.newInputStream(new 
Path(firstFile))) {
+            assertThat(readAll(stream, firstData.length)).isEqualTo(firstData);
+        }
+        try (SeekableInputStream stream = second.newInputStream(new 
Path(secondFile))) {
+            assertThat(readAll(stream, 
secondData.length)).isEqualTo(secondData);
+        }
+        try (SeekableInputStream stream = first.newInputStream(new 
Path(firstFile))) {
+            assertThat(readAll(stream, firstData.length)).isEqualTo(firstData);
+        }
+
+        
assertThat(MockFileIO.globalInputStreamCallCount(firstFile)).isEqualTo(2);
+        first.close();
+        second.close();
+    }
+
+    @Test
+    void testDeserializedCacheIsReleasedAfterAllExistingWrappersClose() throws 
Exception {
+        byte[] data = "released cache data".getBytes();
+        String fileName = "global-index-released-uuid.index";
+
+        MockFileIO delegate = new MockFileIO();
+        delegate.addFile(fileName, data);
+        CachingFileIO original = newCatalogCachingFileIO(delegate, 
localCacheContext());
+
+        CachingFileIO first = InstantiationUtil.clone(original);
+        CachingFileIO second = InstantiationUtil.clone(original);
+        try (SeekableInputStream stream = first.newInputStream(new 
Path(fileName))) {
+            assertThat(readAll(stream, data.length)).isEqualTo(data);
+        }
+        first.close();
+
+        try (SeekableInputStream stream = second.newInputStream(new 
Path(fileName))) {
+            assertThat(readAll(stream, data.length)).isEqualTo(data);
+        }
+        
assertThat(MockFileIO.globalInputStreamCallCount(fileName)).isEqualTo(1);
+        second.close();
+
+        CachingFileIO third = InstantiationUtil.clone(original);
+        try (SeekableInputStream stream = third.newInputStream(new 
Path(fileName))) {
+            assertThat(readAll(stream, data.length)).isEqualTo(data);
+        }
+        
assertThat(MockFileIO.globalInputStreamCallCount(fileName)).isEqualTo(2);
+        third.close();
+    }
+
+    @Test
+    void testDiskCacheIsReusedAfterManagerRecreation() throws Exception {
+        byte[] data = "persistent disk cache data".getBytes();
+        String fileName = "global-index-persistent-uuid.index";
+        CatalogContext context = localDiskCacheContext();
+
+        MockFileIO firstDelegate = new MockFileIO();
+        firstDelegate.addFile(fileName, data);
+        CachingFileIO first = newCatalogCachingFileIO(firstDelegate, context);
+        try (SeekableInputStream stream = first.newInputStream(new 
Path(fileName))) {
+            assertThat(readAll(stream, data.length)).isEqualTo(data);
+        }
+        first.close();
+
+        MockFileIO secondDelegate = new MockFileIO();
+        secondDelegate.addFile(fileName, data);
+        CachingFileIO second = newCatalogCachingFileIO(secondDelegate, 
context);
+        try (SeekableInputStream stream = second.newInputStream(new 
Path(fileName))) {
+            assertThat(readAll(stream, data.length)).isEqualTo(data);
+        }
+        
assertThat(MockFileIO.globalInputStreamCallCount(fileName)).isEqualTo(1);
+        second.close();
+    }
+
+    private CatalogContext localCacheContext() {
+        return localCacheContext(null);
+    }
+
+    private CatalogContext localCacheContext(@Nullable Integer maxSize) {
+        Options options = new Options();
+        options.set(LOCAL_CACHE_ENABLED, true);
+        options.set(LOCAL_CACHE_WHITELIST, "global-index");
+        if (maxSize != null) {
+            options.set(LOCAL_CACHE_MAX_SIZE, MemorySize.ofBytes(maxSize));
+        }
+        return CatalogContext.create(options);
+    }
+
+    private CatalogContext localDiskCacheContext() {
+        Options options = new Options();
+        options.set(LOCAL_CACHE_ENABLED, true);
+        options.set(LOCAL_CACHE_DIR, cacheDir);
+        options.set(LOCAL_CACHE_WHITELIST, "global-index");
+        return CatalogContext.create(options);
+    }
+
+    private CachingFileIO newCatalogCachingFileIO(FileIO delegate, 
CatalogContext context) {
+        return (CachingFileIO)
+                CachingFileIO.wrapWithCachingIfNeeded(
+                        delegate, context, 
CachingFileIO.createCacheManager(context));
+    }
+
     @Test
     void testDataFileNotCached() throws IOException {
         byte[] data = "data content".getBytes();
@@ -352,10 +591,22 @@ class CachingFileIOTest {
     /** Simple in-memory FileIO for testing. */
     private static class MockFileIO implements FileIO {
 
+        private static final Map<String, AtomicInteger> 
GLOBAL_INPUT_STREAM_CALLS =
+                new ConcurrentHashMap<>();
+
         private final Map<String, byte[]> files = new HashMap<>();
         private final Map<String, Integer> fileStatusCalls = new HashMap<>();
         private final Map<String, Integer> newInputStreamCalls = new 
HashMap<>();
 
+        static void resetGlobalInputStreamCalls() {
+            GLOBAL_INPUT_STREAM_CALLS.clear();
+        }
+
+        static int globalInputStreamCallCount(String name) {
+            AtomicInteger count = GLOBAL_INPUT_STREAM_CALLS.get(name);
+            return count == null ? 0 : count.get();
+        }
+
         void addFile(String name, byte[] data) {
             files.put(name, data);
         }
@@ -372,6 +623,9 @@ class CachingFileIOTest {
         public SeekableInputStream newInputStream(Path path) throws 
IOException {
             String name = path.getName();
             newInputStreamCalls.merge(name, 1, Integer::sum);
+            GLOBAL_INPUT_STREAM_CALLS
+                    .computeIfAbsent(name, ignored -> new AtomicInteger())
+                    .incrementAndGet();
             byte[] data = files.get(name);
             if (data == null) {
                 throw new IOException("File not found: " + name);
diff --git 
a/paimon-common/src/test/java/org/apache/paimon/fs/cache/LocalDiskCacheManagerTest.java
 
b/paimon-common/src/test/java/org/apache/paimon/fs/cache/LocalDiskCacheManagerTest.java
index d6fb136cf5..251d93d184 100644
--- 
a/paimon-common/src/test/java/org/apache/paimon/fs/cache/LocalDiskCacheManagerTest.java
+++ 
b/paimon-common/src/test/java/org/apache/paimon/fs/cache/LocalDiskCacheManagerTest.java
@@ -106,6 +106,29 @@ class LocalDiskCacheManagerTest {
         assertThat(cache2.getBlock("f", 1)).isEqualTo(data1);
     }
 
+    @Test
+    void testDifferentBlockSizesDoNotSharePhysicalBlocks() {
+        LocalDiskCacheManager smallBlocks = new 
LocalDiskCacheManager(cacheDir, Long.MAX_VALUE, 4);
+        byte[] data = "four".getBytes();
+        smallBlocks.putBlock("f", 0, data);
+
+        LocalDiskCacheManager largeBlocks = new 
LocalDiskCacheManager(cacheDir, Long.MAX_VALUE, 8);
+        assertThat(largeBlocks.getBlock("f", 0)).isNull();
+        assertThat(largeBlocks.currentSize()).isZero();
+    }
+
+    @Test
+    void testManagerDiscoversBlockWrittenAfterConstruction() {
+        LocalDiskCacheManager first = new LocalDiskCacheManager(cacheDir, 
Long.MAX_VALUE, 64);
+        LocalDiskCacheManager second = new LocalDiskCacheManager(cacheDir, 
Long.MAX_VALUE, 64);
+        byte[] data = "shared block".getBytes();
+
+        first.putBlock("f", 0, data);
+
+        assertThat(second.getBlock("f", 0)).isEqualTo(data);
+        assertThat(second.currentSize()).isEqualTo(data.length);
+    }
+
     @Test
     void testCacheDirCreated() {
         String deepDir = tempDir.resolve("sub").resolve("deep").toString();

Reply via email to