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

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


The following commit(s) were added to refs/heads/main by this push:
     new e4ec3a45 [core] Add opt-in node-shared disk block cache (#1175)
e4ec3a45 is described below

commit e4ec3a45e229ab856cbae59a4733b9fad3e5f76e
Author: XiaoHongbo <[email protected]>
AuthorDate: Tue Oct 20 22:43:23 2026 +0800

    [core] Add opt-in node-shared disk block cache (#1175)
---
 crates/paimon/src/catalog/factory.rs           |  59 +-
 crates/paimon/src/catalog/rest/rest_catalog.rs |  55 +-
 crates/paimon/src/common/options.rs            |   2 +
 crates/paimon/src/io/cache/disk.rs             |   3 +
 crates/paimon/src/io/cache/mod.rs              | 153 ++++-
 crates/paimon/src/io/cache/reader.rs           |  19 +-
 crates/paimon/src/io/cache/shared.rs           | 888 +++++++++++++++++++++++++
 crates/paimon/src/io/cache/state.rs            |   2 +
 crates/paimon/src/io/file_io.rs                | 327 ++++++++-
 crates/paimon/src/table/rest_env.rs            |   5 +-
 docs/src/getting-started.md                    |  15 +-
 11 files changed, 1493 insertions(+), 35 deletions(-)

diff --git a/crates/paimon/src/catalog/factory.rs 
b/crates/paimon/src/catalog/factory.rs
index c531ccd5..81ee7059 100644
--- a/crates/paimon/src/catalog/factory.rs
+++ b/crates/paimon/src/catalog/factory.rs
@@ -89,7 +89,7 @@ impl CatalogFactory {
             .unwrap_or(METASTORE_FILESYSTEM);
 
         match metastore {
-            METASTORE_FILESYSTEM => Self::create_filesystem_catalog(options),
+            METASTORE_FILESYSTEM => 
Self::create_filesystem_catalog(options).await,
             METASTORE_REST => Self::create_rest_catalog(options).await,
             _ => ConfigInvalidSnafu {
                 message: format!(
@@ -101,8 +101,13 @@ impl CatalogFactory {
     }
 
     /// Create a filesystem catalog.
-    fn create_filesystem_catalog(options: Options) -> Result<Arc<dyn Catalog>> 
{
-        let catalog = FileSystemCatalog::new(options)?;
+    async fn create_filesystem_catalog(options: Options) -> Result<Arc<dyn 
Catalog>> {
+        let catalog = tokio::task::spawn_blocking(move || 
FileSystemCatalog::new(options))
+            .await
+            .map_err(|error| crate::Error::UnexpectedError {
+                message: "Filesystem catalog initialization task 
failed".into(),
+                source: Some(Box::new(error)),
+            })??;
         Ok(Arc::new(catalog))
     }
 
@@ -140,6 +145,54 @@ mod tests {
         assert!(result.is_ok());
     }
 
+    #[test]
+    fn test_shared_cache_factory_init_does_not_block_current_thread_runtime() {
+        use std::fs::File;
+        use std::sync::mpsc;
+        use std::time::Duration;
+
+        let directory = tempfile::tempdir().unwrap();
+        let mut options = Options::new();
+        options.set(
+            CatalogOptions::WAREHOUSE,
+            directory.path().join("warehouse").to_str().unwrap(),
+        );
+        options.set(CatalogOptions::LOCAL_CACHE_ENABLED, "true");
+        options.set(CatalogOptions::LOCAL_CACHE_SHARED, "true");
+        options.set(CatalogOptions::LOCAL_CACHE_MAX_SIZE, "1 MiB");
+        options.set(
+            CatalogOptions::LOCAL_CACHE_DIR,
+            directory.path().to_str().unwrap(),
+        );
+        drop(FileSystemCatalog::new(options.clone()).unwrap());
+        let gate = File::open(
+            directory
+                .path()
+                .join("paimon-shared-local-cache-v1/directory.lock"),
+        )
+        .unwrap();
+        gate.lock().unwrap();
+
+        let (tx, rx) = mpsc::channel();
+        let catalog_thread = std::thread::spawn(move || {
+            let runtime = tokio::runtime::Builder::new_current_thread()
+                .enable_all()
+                .build()
+                .unwrap();
+            runtime.block_on(async move {
+                tokio::spawn(async move {
+                    tokio::time::sleep(Duration::from_millis(50)).await;
+                    tx.send(()).unwrap();
+                });
+                CatalogFactory::create(options).await.unwrap();
+            });
+        });
+        let runtime_progressed = 
rx.recv_timeout(Duration::from_secs(2)).is_ok();
+        drop(gate);
+        catalog_thread.join().unwrap();
+        assert!(runtime_progressed, "catalog factory blocked the runtime");
+    }
+
     #[tokio::test]
     async fn test_missing_warehouse_option() {
         let options = Options::new();
diff --git a/crates/paimon/src/catalog/rest/rest_catalog.rs 
b/crates/paimon/src/catalog/rest/rest_catalog.rs
index de88f8d6..661f6d1a 100644
--- a/crates/paimon/src/catalog/rest/rest_catalog.rs
+++ b/crates/paimon/src/catalog/rest/rest_catalog.rs
@@ -36,7 +36,7 @@ use crate::catalog::{
 };
 use crate::common::{CatalogOptions, Options};
 use crate::error::Error;
-use crate::io::cache::{create_local_cache_with_namespace, LocalCache};
+use crate::io::cache::{create_local_cache_with_namespace_async, LocalCache};
 use crate::io::FileIOCacheContext;
 use crate::spec::{Partition, PartitionStatistics, Schema, SchemaChange};
 use crate::table::{RESTEnv, Table};
@@ -90,7 +90,7 @@ impl RESTCatalog {
             .unwrap_or(false);
 
         let api_options = api.options().clone();
-        let local_cache = create_local_cache_with_namespace(&options, 
&api_options)?;
+        let local_cache = create_local_cache_with_namespace_async(&options, 
&api_options).await?;
         let file_io_cache = 
FileIOCacheContext::from_props(api_options.to_map())?;
 
         let fuse_resolver = 
Arc::new(super::FusePathResolver::new(&api_options));
@@ -1229,6 +1229,57 @@ mod tests {
         assert!(catalog.has_local_cache());
     }
 
+    #[test]
+    fn test_shared_cache_catalog_init_does_not_block_current_thread_runtime() {
+        use std::fs::File;
+        use std::sync::mpsc;
+        use std::time::Duration;
+
+        let directory = tempfile::tempdir().unwrap();
+        let mut options = Options::new();
+        options.set(CatalogOptions::URI, "http://localhost:1";);
+        options.set(CatalogOptions::WAREHOUSE, "test-warehouse");
+        options.set("token.provider", "bear");
+        options.set("token", "test-token");
+        options.set(CatalogOptions::LOCAL_CACHE_ENABLED, "true");
+        options.set(CatalogOptions::LOCAL_CACHE_SHARED, "true");
+        options.set(CatalogOptions::LOCAL_CACHE_MAX_SIZE, "1 MiB");
+        options.set(
+            CatalogOptions::LOCAL_CACHE_DIR,
+            directory.path().to_str().unwrap(),
+        );
+        drop(crate::io::cache::create_local_cache_with_namespace(&options, 
&options).unwrap());
+        let gate = File::open(
+            directory
+                .path()
+                .join("paimon-shared-local-cache-v1/directory.lock"),
+        )
+        .unwrap();
+        gate.lock().unwrap();
+
+        let (tx, rx) = mpsc::channel();
+        let catalog_thread = std::thread::spawn(move || {
+            let runtime = tokio::runtime::Builder::new_current_thread()
+                .enable_all()
+                .build()
+                .unwrap();
+            runtime.block_on(async move {
+                tokio::spawn(async move {
+                    tokio::time::sleep(Duration::from_millis(50)).await;
+                    tx.send(()).unwrap();
+                });
+                RESTCatalog::new(options, false).await.unwrap();
+            });
+        });
+        let runtime_progressed = 
rx.recv_timeout(Duration::from_secs(2)).is_ok();
+        drop(gate);
+        catalog_thread.join().unwrap();
+        assert!(
+            runtime_progressed,
+            "catalog initialization blocked the runtime"
+        );
+    }
+
     #[test]
     fn test_tag_error_mapping() {
         let identifier = Identifier::new("db", "table");
diff --git a/crates/paimon/src/common/options.rs 
b/crates/paimon/src/common/options.rs
index c75f1954..eeefb7d0 100644
--- a/crates/paimon/src/common/options.rs
+++ b/crates/paimon/src/common/options.rs
@@ -81,6 +81,8 @@ impl CatalogOptions {
 
     /// Directory for the local disk block cache. If unset, an in-memory cache 
is used.
     pub const LOCAL_CACHE_DIR: &'static str = "local-cache.dir";
+    /// Share a node-local disk cache between cooperating processes.
+    pub const LOCAL_CACHE_SHARED: &'static str = "local-cache.shared";
 
     /// Maximum total size of the local block cache.
     pub const LOCAL_CACHE_MAX_SIZE: &'static str = "local-cache.max-size";
diff --git a/crates/paimon/src/io/cache/disk.rs 
b/crates/paimon/src/io/cache/disk.rs
index b731e303..3b217e04 100644
--- a/crates/paimon/src/io/cache/disk.rs
+++ b/crates/paimon/src/io/cache/disk.rs
@@ -28,6 +28,9 @@ use super::priority_lru::DEFAULT_HIGH_PRIORITY_POOL_RATIO;
 use super::priority_lru::{protected_limit, PriorityLru};
 use super::state::{BlockKey, CacheCoordinator, LogicalPath};
 
+#[path = "shared.rs"]
+pub(super) mod shared;
+
 const CACHE_MAGIC: &[u8; 8] = b"PAIMONLC";
 const CACHE_FORMAT_VERSION: u8 = 2;
 const FIXED_HEADER_LEN: usize = CACHE_MAGIC.len() + 1 + 4 + 4 + 8 + 8 + 8;
diff --git a/crates/paimon/src/io/cache/mod.rs 
b/crates/paimon/src/io/cache/mod.rs
index 42eb32af..701f305b 100644
--- a/crates/paimon/src/io/cache/mod.rs
+++ b/crates/paimon/src/io/cache/mod.rs
@@ -33,6 +33,7 @@ use std::collections::HashSet;
 use std::path::PathBuf;
 use std::sync::Arc;
 
+use disk::shared::SharedDiskCache;
 use disk::DiskCache;
 pub(super) use reader::CachedFileReader;
 
@@ -53,6 +54,7 @@ pub(crate) struct LocalCache {
 enum CacheBackend {
     Memory(MemoryCache),
     Disk(Arc<DiskCache>),
+    SharedDisk(SharedDiskCache),
     External(Arc<dyn FileBlockCache>),
 }
 
@@ -89,7 +91,23 @@ impl LocalCache {
             .and_then(|capacity| usize::try_from(capacity).ok())
             .unwrap_or(DEFAULT_FILE_SIZE_CAPACITY)
             .clamp(1, DEFAULT_FILE_SIZE_CAPACITY);
-        let (backend, coordinator) = if let Some(dir) = config.dir {
+        let (backend, coordinator) = if config.shared {
+            (
+                CacheBackend::SharedDisk(SharedDiskCache::new_with_ratio(
+                    config
+                        .dir
+                        .ok_or_else(|| crate::Error::ConfigInvalid {
+                            message: "Shared local cache requires 
local-cache.dir".into(),
+                        })?
+                        .join("paimon-shared-local-cache-v1"),
+                    config.max_size.ok_or_else(|| crate::Error::ConfigInvalid {
+                        message: "Shared local cache requires 
local-cache.max-size".into(),
+                    })?,
+                    ratio,
+                )?),
+                Arc::new(CacheCoordinator::default()),
+            )
+        } else if let Some(dir) = config.dir {
             let disk = DiskCache::shared(dir.join(CACHE_DIRECTORY_NAME), 
config.max_size, ratio)?;
             let coordinator = disk.coordinator();
             (CacheBackend::Disk(disk), coordinator)
@@ -135,6 +153,7 @@ impl LocalCache {
         let payload = match &self.backend {
             CacheBackend::Memory(memory) => memory.get_block(key),
             CacheBackend::Disk(disk) => disk.get_block(key).await,
+            CacheBackend::SharedDisk(disk) => disk.get(key, 
&token.shared_generation).await,
             CacheBackend::External(cache) => {
                 let start = key.block_index.checked_mul(key.block_size)?;
                 let length = u64::try_from(expected_len).ok()?;
@@ -158,6 +177,9 @@ impl LocalCache {
         match &self.backend {
             CacheBackend::Memory(memory) => memory.put_block(key, payload),
             CacheBackend::Disk(disk) => disk.put_block(key, payload).await,
+            CacheBackend::SharedDisk(disk) => {
+                disk.put(key, payload, &token.shared_generation).await
+            }
             CacheBackend::External(cache) => {
                 if let Some(offset) = 
key.block_index.checked_mul(key.block_size) {
                     cache.put(&key.path, offset, payload).await;
@@ -170,12 +192,28 @@ impl LocalCache {
         match &self.backend {
             CacheBackend::Memory(memory) => memory.remove_block(key),
             CacheBackend::Disk(disk) => disk.remove_block(key).await,
+            CacheBackend::SharedDisk(disk) => {
+                if let Err(error) = disk.invalidate().await {
+                    log::debug!("Failed to evict shared cache block: {error}");
+                }
+            }
             CacheBackend::External(cache) => 
cache.invalidate_path(&key.path).await,
         }
     }
 
     pub(super) fn read_token(&self, path: &str) -> CacheReadToken {
-        self.coordinator.read_token(&self.namespace, path)
+        let mut token = self.coordinator.read_token(&self.namespace, path);
+        if let CacheBackend::SharedDisk(disk) = &self.backend {
+            token.shared_generation = disk.generation();
+        }
+        token
+    }
+
+    async fn shared_load_lock(&self, key: &BlockKey) -> Option<std::fs::File> {
+        match &self.backend {
+            CacheBackend::SharedDisk(disk) => disk.load_lock(key).await,
+            _ => None,
+        }
     }
 
     async fn block_load_lock(&self, key: &BlockKey) -> 
Arc<tokio::sync::Mutex<()>> {
@@ -187,6 +225,10 @@ impl LocalCache {
     }
 
     pub(super) async fn file_size(&self, path: &str, token: &CacheReadToken) 
-> Option<u64> {
+        // Process-local file sizes cannot observe another process's 
invalidation.
+        if matches!(self.backend, CacheBackend::SharedDisk(_)) {
+            return None;
+        }
         let _prefix_guard = self.coordinator.prefix_read_guard().await;
         let _publish_guard = token.publish_guard().await;
         token
@@ -196,6 +238,9 @@ impl LocalCache {
     }
 
     pub(super) async fn put_file_size(&self, path: &str, size: u64, token: 
&CacheReadToken) {
+        if matches!(self.backend, CacheBackend::SharedDisk(_)) {
+            return;
+        }
         let _prefix_guard = self.coordinator.prefix_read_guard().await;
         let _publish_guard = token.publish_guard().await;
         if !token.is_current() {
@@ -205,7 +250,7 @@ impl LocalCache {
             .put_file_size(&self.namespace, path, size, 
self.file_size_capacity);
     }
 
-    pub(super) async fn invalidate_path(&self, path: &str) {
+    pub(super) async fn invalidate_path(&self, path: &str) -> 
crate::Result<()> {
         let _guard = self
             .coordinator
             .begin_path_invalidation(&self.namespace, path)
@@ -213,11 +258,15 @@ impl LocalCache {
         match &self.backend {
             CacheBackend::Memory(memory) => 
memory.invalidate_path(&self.namespace, path),
             CacheBackend::Disk(disk) => disk.invalidate_path(&self.namespace, 
path).await,
+            CacheBackend::SharedDisk(disk) => {
+                disk.invalidate().await.map_err(invalidation_error)?
+            }
             CacheBackend::External(cache) => cache.invalidate_path(path).await,
         }
+        Ok(())
     }
 
-    pub(super) async fn invalidate_prefix(&self, prefix: &str) {
+    pub(super) async fn invalidate_prefix(&self, prefix: &str) -> 
crate::Result<()> {
         if let CacheBackend::Disk(disk) = &self.backend {
             disk.ensure_recovered().await;
         }
@@ -228,8 +277,35 @@ impl LocalCache {
         match &self.backend {
             CacheBackend::Memory(memory) => 
memory.invalidate_prefix(&self.namespace, prefix),
             CacheBackend::Disk(disk) => 
disk.invalidate_prefix(&self.namespace, prefix).await,
+            CacheBackend::SharedDisk(disk) => {
+                disk.invalidate().await.map_err(invalidation_error)?
+            }
             CacheBackend::External(cache) => 
cache.invalidate_prefix(prefix).await,
         }
+        Ok(())
+    }
+
+    pub(in crate::io) async fn begin_shared_invalidation(
+        &self,
+    ) -> crate::Result<Option<disk::shared::SharedInvalidationGuard>> {
+        match &self.backend {
+            CacheBackend::SharedDisk(disk) => {
+                disk.begin_invalidation().await.map(Some).map_err(|error| {
+                    crate::Error::UnexpectedError {
+                        message: "Failed to establish shared cache mutation 
fence".into(),
+                        source: Some(Box::new(error)),
+                    }
+                })
+            }
+            _ => Ok(None),
+        }
+    }
+}
+
+fn invalidation_error(error: std::io::Error) -> crate::Error {
+    crate::Error::UnexpectedError {
+        message: "Shared cache invalidation failed; storage may already have 
been modified".into(),
+        source: Some(Box::new(error)),
     }
 }
 
@@ -271,8 +347,34 @@ fn parse_high_priority_ratio(options: &Options) -> 
crate::Result<f64> {
     Ok(ratio)
 }
 
+pub(crate) async fn create_local_cache_with_namespace_async(
+    cache_options: &Options,
+    namespace_options: &Options,
+) -> crate::Result<Option<Arc<LocalCache>>> {
+    let Some(config) =
+        LocalCacheConfig::from_options_with_namespace(cache_options, 
namespace_options)?
+    else {
+        return Ok(None);
+    };
+    let ratio = parse_high_priority_ratio(cache_options)?;
+    if config.shared {
+        tokio::task::spawn_blocking(move || LocalCache::new_with_ratio(config, 
ratio).map(Arc::new))
+            .await
+            .map_err(|error| crate::Error::UnexpectedError {
+                message: "Shared local cache initialization task 
failed".into(),
+                source: Some(Box::new(error)),
+            })?
+            .map(Some)
+    } else {
+        LocalCache::new_with_ratio(config, ratio)
+            .map(Arc::new)
+            .map(Some)
+    }
+}
+
 #[derive(Debug)]
 pub(crate) struct LocalCacheConfig {
+    shared: bool,
     dir: Option<PathBuf>,
     namespace: String,
     max_size: Option<u64>,
@@ -338,6 +440,19 @@ impl LocalCacheConfig {
             .map(String::as_str)
             .unwrap_or("meta,global-index");
         Ok(Some(Self {
+            shared: match options
+                .get(CatalogOptions::LOCAL_CACHE_SHARED)
+                .map(|v| v.trim())
+            {
+                None => false,
+                Some(v) if v.eq_ignore_ascii_case("true") => true,
+                Some(v) if v.eq_ignore_ascii_case("false") => false,
+                Some(_) => {
+                    return Err(crate::Error::ConfigInvalid {
+                        message: "Invalid boolean for 
local-cache.shared".into(),
+                    })
+                }
+            },
             dir,
             namespace: catalog_namespace(namespace_options),
             max_size,
@@ -521,6 +636,7 @@ mod tests {
     async fn test_local_cache_file_size_is_removed_with_path_invalidation() {
         let directory = tempfile::tempdir().unwrap();
         let cache = LocalCache::new(LocalCacheConfig {
+            shared: false,
             dir: Some(directory.path().to_path_buf()),
             namespace: "test".to_string(),
             max_size: None,
@@ -534,7 +650,7 @@ mod tests {
         assert_eq!(cache.file_size(path, &token).await, None);
         cache.put_file_size(path, 42, &token).await;
         assert_eq!(cache.file_size(path, &token).await, Some(42));
-        cache.invalidate_path(path).await;
+        cache.invalidate_path(path).await.unwrap();
         assert_eq!(cache.file_size(path, &token).await, None);
     }
 
@@ -560,10 +676,29 @@ mod tests {
         }
     }
 
+    #[test]
+    fn test_shared_cache_requires_directory_and_explicit_capacity() {
+        let directory = tempfile::tempdir().unwrap();
+        let mut options = Options::default();
+        options.set(CatalogOptions::LOCAL_CACHE_ENABLED, "true");
+        options.set(CatalogOptions::LOCAL_CACHE_SHARED, "true");
+        assert!(create_local_cache(&options).is_err());
+        options.set(
+            CatalogOptions::LOCAL_CACHE_DIR,
+            directory.path().to_str().unwrap(),
+        );
+        assert!(create_local_cache(&options).is_err());
+        options.set(CatalogOptions::LOCAL_CACHE_MAX_SIZE, "1 MiB");
+        assert!(create_local_cache(&options).unwrap().is_some());
+        options.set(CatalogOptions::LOCAL_CACHE_SHARED, "invalid");
+        assert!(create_local_cache(&options).is_err());
+    }
+
     #[tokio::test]
     async fn 
test_local_cache_file_size_is_invalidated_across_shared_instances() {
         let directory = tempfile::tempdir().unwrap();
         let config = || LocalCacheConfig {
+            shared: false,
             dir: Some(directory.path().to_path_buf()),
             namespace: "test".to_string(),
             max_size: None,
@@ -577,7 +712,7 @@ mod tests {
 
         first.put_file_size(path, 42, &token).await;
         assert_eq!(second.file_size(path, &token).await, Some(42));
-        second.invalidate_path(path).await;
+        second.invalidate_path(path).await.unwrap();
         let current_token = first.read_token(path);
         assert_eq!(first.file_size(path, &current_token).await, None);
     }
@@ -586,6 +721,7 @@ mod tests {
     async fn test_stale_file_size_cannot_republish_after_invalidation() {
         let directory = tempfile::tempdir().unwrap();
         let config = || LocalCacheConfig {
+            shared: false,
             dir: Some(directory.path().to_path_buf()),
             namespace: "test".to_string(),
             max_size: None,
@@ -598,7 +734,7 @@ mod tests {
         let stale_token = first.read_token(path);
 
         assert_eq!(first.file_size(path, &stale_token).await, None);
-        second.invalidate_path(path).await;
+        second.invalidate_path(path).await.unwrap();
         first.put_file_size(path, 42, &stale_token).await;
 
         let current_token = first.read_token(path);
@@ -609,6 +745,7 @@ mod tests {
     fn test_local_cache_uses_whitelist_and_bypasses_mutable_files() {
         let directory = tempfile::tempdir().unwrap();
         let cache = LocalCache::new(LocalCacheConfig {
+            shared: false,
             dir: Some(directory.path().to_path_buf()),
             namespace: "test".to_string(),
             max_size: None,
@@ -638,6 +775,7 @@ mod tests {
         std::fs::write(&nested, b"nested foreign").unwrap();
 
         LocalCache::new(LocalCacheConfig {
+            shared: false,
             dir: Some(directory.path().to_path_buf()),
             namespace: "test".to_string(),
             max_size: None,
@@ -658,6 +796,7 @@ mod tests {
     async fn test_local_cache_bounds_file_size_entries() {
         let directory = tempfile::tempdir().unwrap();
         let cache = LocalCache::new(LocalCacheConfig {
+            shared: false,
             dir: Some(directory.path().to_path_buf()),
             namespace: "test".to_string(),
             max_size: Some(8),
diff --git a/crates/paimon/src/io/cache/reader.rs 
b/crates/paimon/src/io/cache/reader.rs
index edb00a0a..bcb83ddd 100644
--- a/crates/paimon/src/io/cache/reader.rs
+++ b/crates/paimon/src/io/cache/reader.rs
@@ -83,6 +83,7 @@ impl CachedFileReader {
 
         let load_lock = self.cache.block_load_lock(&key).await;
         let load_guard = load_lock.lock().await;
+        let _shared_guard = self.cache.shared_load_lock(&key).await;
         let result = if let Some(payload) = self
             .cache
             .get_block(&key, expected_len, &self.read_token)
@@ -140,6 +141,7 @@ impl CachedFileReader {
         let first_key = self.cache.block_key(&self.path, 0);
         let load_lock = self.cache.block_load_lock(&first_key).await;
         let load_guard = load_lock.lock().await;
+        let _shared_guard = self.cache.shared_load_lock(&first_key).await;
         let result = if let Some(payload) = self.read_cached_full().await {
             Ok(payload)
         } else {
@@ -270,6 +272,7 @@ mod tests {
         });
         let cache = Arc::new(
             LocalCache::new(LocalCacheConfig {
+                shared: false,
                 dir: None,
                 namespace: "test".to_string(),
                 max_size: None,
@@ -305,6 +308,7 @@ mod tests {
         });
         let cache = Arc::new(
             LocalCache::new(LocalCacheConfig {
+                shared: false,
                 dir: None,
                 namespace: "test".to_string(),
                 max_size: Some(16),
@@ -360,6 +364,7 @@ mod tests {
         });
         let cache = Arc::new(
             LocalCache::new(LocalCacheConfig {
+                shared: false,
                 dir: None,
                 namespace: "test".to_string(),
                 max_size: None,
@@ -390,6 +395,7 @@ mod tests {
             reads: AtomicUsize::new(0),
         });
         let config = || LocalCacheConfig {
+            shared: false,
             dir: Some(directory.path().to_path_buf()),
             namespace: "test".to_string(),
             max_size: None,
@@ -437,6 +443,7 @@ mod tests {
         let path = "s3://bucket/table/snapshot/snapshot-1";
         let cache = Arc::new(
             LocalCache::new(LocalCacheConfig {
+                shared: false,
                 dir: None,
                 namespace: "test".to_string(),
                 max_size: None,
@@ -460,7 +467,7 @@ mod tests {
         let old_load = tokio::spawn(async move { old_reader.read(0..4).await 
});
 
         started.notified().await;
-        cache.invalidate_path(path).await;
+        cache.invalidate_path(path).await.unwrap();
         release.notify_one();
         assert_eq!(
             old_load.await.unwrap().unwrap(),
@@ -486,6 +493,7 @@ mod tests {
         let path = "s3://bucket/table/snapshot/snapshot-1";
         let cache_a = Arc::new(
             LocalCache::new(LocalCacheConfig {
+                shared: false,
                 dir: Some(directory.path().to_path_buf()),
                 namespace: "test".to_string(),
                 max_size: None,
@@ -496,6 +504,7 @@ mod tests {
         );
         let cache_b = Arc::new(
             LocalCache::new(LocalCacheConfig {
+                shared: false,
                 dir: Some(directory.path().to_path_buf()),
                 namespace: "test".to_string(),
                 max_size: None,
@@ -519,7 +528,7 @@ mod tests {
         let old_load = tokio::spawn(async move { old_reader.read(0..4).await 
});
 
         started.notified().await;
-        cache_b.invalidate_path(path).await;
+        cache_b.invalidate_path(path).await.unwrap();
         release.notify_one();
         assert_eq!(
             old_load.await.unwrap().unwrap(),
@@ -547,6 +556,7 @@ mod tests {
         let observed_path = "s3://bucket/table/snapshot/snapshot-2";
         let blocked_path = "s3://bucket/table/snapshot/snapshot-3";
         let config = || LocalCacheConfig {
+            shared: false,
             dir: Some(directory.path().to_path_buf()),
             namespace: "test".to_string(),
             max_size: None,
@@ -613,7 +623,7 @@ mod tests {
             "file-size lookup completed before prefix invalidation removed the 
old entry"
         );
         drop(blocked_guard);
-        invalidation.await.unwrap();
+        invalidation.await.unwrap().unwrap();
         assert_eq!(
             current_load.await.unwrap().unwrap(),
             Bytes::from_static(b"new!")
@@ -693,6 +703,7 @@ mod tests {
         });
         let cache = Arc::new(
             LocalCache::new(LocalCacheConfig {
+                shared: false,
                 dir: Some(directory.path().to_path_buf()),
                 namespace: "test".to_string(),
                 max_size: None,
@@ -727,6 +738,7 @@ mod tests {
         let directory = tempfile::tempdir().unwrap();
         let cache = Arc::new(
             LocalCache::new(LocalCacheConfig {
+                shared: false,
                 dir: Some(directory.path().to_path_buf()),
                 namespace: "test".to_string(),
                 max_size: None,
@@ -748,6 +760,7 @@ mod tests {
         });
         let cache = Arc::new(
             LocalCache::new(LocalCacheConfig {
+                shared: false,
                 dir: None,
                 namespace: "test".to_string(),
                 max_size: None,
diff --git a/crates/paimon/src/io/cache/shared.rs 
b/crates/paimon/src/io/cache/shared.rs
new file mode 100644
index 00000000..0ace0c0e
--- /dev/null
+++ b/crates/paimon/src/io/cache/shared.rs
@@ -0,0 +1,888 @@
+// 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.
+
+//! Opt-in node-local cache. Block files, not a process-local index, are 
authoritative.
+//! A shared directory lock protects cache hits, while an exclusive lock 
serializes
+//! publication, eviction, and invalidation. File-striped locks coalesce 
downloads.
+
+use super::*;
+use std::fs::{File, OpenOptions};
+use std::io::{self, Write};
+use std::time::Duration;
+
+const MUTATION_MARKER: &str = "mutation-pending";
+
+#[derive(Debug)]
+pub(crate) struct SharedDiskCache {
+    root: PathBuf,
+    max_size: u64,
+    high_priority_ratio: f64,
+}
+
+fn open_lock(path: &Path) -> io::Result<File> {
+    let mut options = OpenOptions::new();
+    options.read(true).write(true).create(true).truncate(false);
+    #[cfg(unix)]
+    {
+        use std::os::unix::fs::OpenOptionsExt;
+        options.mode(0o600);
+    }
+    options.open(path)
+}
+
+/// Try-lock polling avoids occupying a blocking thread while another process
+/// performs remote I/O. Dropping the file (including cancellation/exit) 
releases it.
+async fn lock(path: PathBuf) -> Option<File> {
+    let file = tokio::task::spawn_blocking(move || open_lock(&path))
+        .await
+        .ok()?
+        .ok()?;
+    let deadline = tokio::time::Instant::now() + Duration::from_secs(2);
+    loop {
+        match file.try_lock() {
+            Ok(()) => return Some(file),
+            Err(std::fs::TryLockError::WouldBlock) => {
+                if tokio::time::Instant::now() >= deadline {
+                    return None;
+                }
+                tokio::time::sleep(Duration::from_millis(10)).await;
+            }
+            Err(std::fs::TryLockError::Error(error)) => {
+                log::debug!("Shared cache lock unavailable: {error}");
+                return None;
+            }
+        }
+    }
+}
+
+async fn read_lock(path: PathBuf) -> Option<File> {
+    let file = tokio::task::spawn_blocking(move || open_lock(&path))
+        .await
+        .ok()?
+        .ok()?;
+    let deadline = tokio::time::Instant::now() + Duration::from_secs(2);
+    loop {
+        match File::try_lock_shared(&file) {
+            Ok(()) => return Some(file),
+            Err(std::fs::TryLockError::WouldBlock) => {
+                if tokio::time::Instant::now() >= deadline {
+                    return None;
+                }
+                tokio::time::sleep(Duration::from_millis(10)).await;
+            }
+            Err(std::fs::TryLockError::Error(error)) => {
+                log::debug!("Shared cache read lock unavailable: {error}");
+                return None;
+            }
+        }
+    }
+}
+
+/// Mutating storage after a cache invalidation timeout can leave cached data
+/// reachable with no safe way to retry the mutation. Mutation fencing 
therefore
+/// waits for the exclusive gate instead of falling back after the normal 
timeout.
+async fn required_lock(path: PathBuf) -> io::Result<File> {
+    let file = tokio::task::spawn_blocking(move || open_lock(&path))
+        .await
+        .map_err(io::Error::other)??;
+    loop {
+        match file.try_lock() {
+            Ok(()) => return Ok(file),
+            Err(std::fs::TryLockError::WouldBlock) => {
+                tokio::time::sleep(Duration::from_millis(10)).await;
+            }
+            Err(std::fs::TryLockError::Error(error)) => return Err(error),
+        }
+    }
+}
+
+#[derive(Debug)]
+pub(in crate::io) struct SharedInvalidationGuard {
+    root: PathBuf,
+    gate: File,
+    clear_marker: bool,
+}
+
+impl SharedInvalidationGuard {
+    pub(in crate::io) async fn finish(self) {
+        let Self {
+            root,
+            gate,
+            clear_marker,
+        } = self;
+        let result = tokio::task::spawn_blocking(move || {
+            let _gate = gate;
+            finish_invalidation(&root)?;
+            if clear_marker {
+                std::fs::remove_file(root.join(MUTATION_MARKER))?;
+            }
+            Ok::<(), io::Error>(())
+        })
+        .await;
+        // Cleanup failure leaves the mutation marker in place, so uncertain
+        // blocks remain inaccessible.
+        match result {
+            Ok(Ok(())) => (),
+            Ok(Err(error)) => log::debug!("Shared cache invalidation cleanup 
failed: {error}"),
+            Err(error) => log::debug!("Shared cache invalidation task failed: 
{error}"),
+        }
+    }
+}
+
+impl SharedDiskCache {
+    #[cfg(test)]
+    pub(crate) fn new(root: PathBuf, max_size: u64) -> crate::Result<Self> {
+        Self::new_with_ratio(root, max_size, DEFAULT_HIGH_PRIORITY_POOL_RATIO)
+    }
+
+    pub(crate) fn new_with_ratio(
+        root: PathBuf,
+        max_size: u64,
+        high_priority_ratio: f64,
+    ) -> crate::Result<Self> {
+        let root = prepare_cache_root(&root)?;
+        let initialize = || -> io::Result<()> {
+            let gate = open_lock(&root.join("directory.lock"))?;
+            gate.lock()?;
+            let limit_path = root.join("capacity");
+            match std::fs::read_to_string(&limit_path) {
+                Ok(value) if value == max_size.to_string() => (),
+                Ok(_) => {
+                    return Err(io::Error::other(
+                        "Shared cache participants must use the same 
local-cache.max-size",
+                    ))
+                }
+                Err(e) if e.kind() == io::ErrorKind::NotFound => {
+                    std::fs::write(limit_path, max_size.to_string())?;
+                }
+                Err(e) => return Err(e),
+            }
+            let ratio_path = root.join("high-priority-pool-ratio");
+            let ratio_bits = high_priority_ratio.to_bits().to_string();
+            match std::fs::read_to_string(&ratio_path) {
+                Ok(value) if value == ratio_bits => (),
+                Ok(_) => {
+                    return Err(io::Error::other(
+                        "Shared cache participants must use the same 
local-cache.high-priority-pool-ratio",
+                    ))
+                }
+                Err(e) if e.kind() == io::ErrorKind::NotFound => {
+                    std::fs::write(ratio_path, ratio_bits)?;
+                }
+                Err(e) => return Err(e),
+            }
+            if read_generation(&root).is_none() {
+                // No block can be trusted without the epoch used to encode its
+                // key. Clear unknown blocks before publishing a fresh epoch.
+                std::fs::write(root.join("dirty"), b"")?;
+                let used = recover_usage(&root, 0, high_priority_ratio)?;
+                rotate_generation(&root)?;
+                finish_usage(&root, used)?;
+            }
+            // Clean participants share persisted accounting; do not rescan 
every
+            // block whenever another worker starts.
+            if clean_usage(&root, max_size).is_none() {
+                std::fs::write(root.join("dirty"), b"")?;
+                let used = recover_usage(&root, max_size, 
high_priority_ratio)?;
+                finish_usage(&root, used)?;
+            }
+            Ok(())
+        };
+        initialize().map_err(|e| crate::Error::ConfigInvalid {
+            message: e.to_string(),
+        })?;
+        Ok(Self {
+            root,
+            max_size,
+            high_priority_ratio,
+        })
+    }
+
+    pub(crate) fn generation(&self) -> Option<String> {
+        let generation = read_generation(&self.root)?;
+        mutation_cleared(&self.root).then_some(generation)
+    }
+
+    pub(crate) async fn load_lock(&self, key: &BlockKey) -> Option<File> {
+        // All blocks of one file use the same stripe, also coordinating 
read_full.
+        let stripe =
+            BlockKey::with_namespace(&key.namespace, &key.path, 0, 
0).cache_relative_path();
+        lock(self.root.join(format!(
+            "load-{}.lock",
+            stripe.components().next()?.as_os_str().to_string_lossy()
+        )))
+        .await
+    }
+
+    pub(crate) async fn get(&self, key: &BlockKey, generation: 
&Option<String>) -> Option<Bytes> {
+        if generation.is_none() {
+            return None;
+        }
+        let gate = read_lock(self.root.join("directory.lock")).await?;
+        let root = self.root.clone();
+        let key = generation_key(key, generation.as_ref()?);
+        let generation = generation.clone();
+        tokio::task::spawn_blocking(move || {
+            let _gate = gate;
+            if !mutation_cleared(&root)
+                || std::fs::read_to_string(root.join("generation")).ok() != 
generation
+            {
+                return None;
+            }
+            let path = root.join(key.cache_relative_path());
+            let data = std::fs::read(&path).ok()?;
+            let payload = decode_block(&key, &data).ok()?;
+            // Persist recency so eviction observes hits from every process.
+            if let Ok(file) = File::open(path) {
+                let _ = file.set_modified(std::time::SystemTime::now());
+            }
+            Some(payload)
+        })
+        .await
+        .ok()
+        .flatten()
+    }
+
+    pub(crate) async fn put(&self, key: &BlockKey, payload: Bytes, generation: 
&Option<String>) {
+        if generation.is_none() {
+            return;
+        }
+        let Some(gate) = lock(self.root.join("directory.lock")).await else {
+            return;
+        };
+        let root = self.root.clone();
+        let key = generation_key(key, generation.as_ref().unwrap());
+        let generation = generation.clone();
+        let max_size = self.max_size;
+        let high_priority_ratio = self.high_priority_ratio;
+        let result = tokio::task::spawn_blocking(move || -> io::Result<()> {
+            let _gate = gate;
+            if !mutation_cleared(&root)
+                || std::fs::read_to_string(root.join("generation")).ok() != 
generation
+            {
+                return Ok(());
+            }
+            let encoded = encode_block(&key, &payload);
+            if encoded.len() as u64 > max_size {
+                return Ok(());
+            }
+            let path = root.join(key.cache_relative_path());
+            let mut used = if root.join("dirty").exists() {
+                recover_usage(&root, max_size, high_priority_ratio)?
+            } else {
+                match std::fs::read_to_string(root.join("usage"))
+                    .ok()
+                    .and_then(|s| s.parse::<u64>().ok())
+                {
+                    Some(used) => used,
+                    None => recover_usage(&root, max_size, 
high_priority_ratio)?,
+                }
+            };
+            // Keep the marker until publication and accounting both complete.
+            // A killed process leaves it for the next writer to recover.
+            std::fs::write(root.join("dirty"), b"")?;
+            let mut previous = std::fs::metadata(&path).map(|m| 
m.len()).unwrap_or(0);
+            if used
+                .saturating_sub(previous)
+                .saturating_add(encoded.len() as u64)
+                > max_size
+            {
+                // Evict in batches, not one full directory scan per new block.
+                used = recover_usage(
+                    &root,
+                    (max_size - encoded.len() as u64).min(max_size - max_size 
/ 10),
+                    high_priority_ratio,
+                )?;
+                previous = std::fs::metadata(&path).map(|m| 
m.len()).unwrap_or(0);
+            }
+            let parent = path.parent().expect("cache shard");
+            std::fs::create_dir_all(parent)?;
+            let temporary = parent.join(format!(
+                ".{}.tmp.{}",
+                path.file_name().unwrap().to_string_lossy(),
+                uuid::Uuid::new_v4()
+            ));
+            let mut options = OpenOptions::new();
+            options.write(true).create_new(true);
+            #[cfg(unix)]
+            {
+                use std::os::unix::fs::OpenOptionsExt;
+                options.mode(0o600);
+            }
+            let publish = (|| {
+                let mut file = options.open(&temporary)?;
+                file.write_all(&encoded)?;
+                drop(file);
+                std::fs::rename(&temporary, &path)
+            })();
+            if publish.is_err() {
+                let _ = std::fs::remove_file(&temporary);
+            }
+            publish?;
+            finish_usage(
+                &root,
+                used.saturating_sub(previous)
+                    .saturating_add(encoded.len() as u64),
+            )
+        })
+        .await;
+        if !matches!(result, Ok(Ok(()))) {
+            log::debug!("Shared cache publication failed");
+        }
+    }
+
+    pub(crate) async fn invalidate(&self) -> io::Result<()> {
+        let gate = lock(self.root.join("directory.lock"))
+            .await
+            .ok_or_else(|| {
+                io::Error::other("Shared cache invalidation could not acquire 
directory lock")
+            })?;
+        let root = self.root.clone();
+        tokio::task::spawn_blocking(move || -> io::Result<()> {
+            let _gate = gate;
+            start_invalidation(&root)?;
+            finish_invalidation(&root)
+        })
+        .await
+        .map_err(io::Error::other)?
+    }
+
+    pub(in crate::io) async fn begin_invalidation(&self) -> 
io::Result<SharedInvalidationGuard> {
+        let gate = required_lock(self.root.join("directory.lock")).await?;
+        let root = self.root.clone();
+        tokio::task::spawn_blocking(move || {
+            // Unlike the accounting dirty marker, this survives cancellation.
+            let clear_marker = match OpenOptions::new()
+                .write(true)
+                .create_new(true)
+                .open(root.join(MUTATION_MARKER))
+            {
+                Ok(_) => true,
+                Err(error) if error.kind() == io::ErrorKind::AlreadyExists => 
false,
+                Err(error) => return Err(error),
+            };
+            start_invalidation(&root)?;
+            Ok(SharedInvalidationGuard {
+                root,
+                gate,
+                clear_marker,
+            })
+        })
+        .await
+        .map_err(io::Error::other)?
+    }
+}
+
+fn read_generation(root: &Path) -> Option<String> {
+    let value = std::fs::read_to_string(root.join("generation")).ok()?;
+    uuid::Uuid::parse_str(&value).ok()?;
+    Some(value)
+}
+
+fn mutation_cleared(root: &Path) -> bool {
+    matches!(
+        std::fs::symlink_metadata(root.join(MUTATION_MARKER)),
+        Err(error) if error.kind() == io::ErrorKind::NotFound
+    )
+}
+
+// The mutation and accounting helpers below are only called while holding the
+// exclusive directory lock.
+fn rotate_generation(root: &Path) -> io::Result<()> {
+    let temporary = root.join("generation.tmp");
+    std::fs::write(&temporary, uuid::Uuid::new_v4().to_string())?;
+    std::fs::rename(temporary, root.join("generation"))
+}
+
+fn start_invalidation(root: &Path) -> io::Result<()> {
+    // Keep recovery enabled until block cleanup and accounting both complete.
+    std::fs::write(root.join("dirty"), b"")?;
+    // Epoch is part of each block's key. Rotation makes every old block
+    // unreachable even if cleanup fails or this process exits midway.
+    rotate_generation(root)
+}
+
+fn finish_invalidation(root: &Path) -> io::Result<()> {
+    // This intentionally invalidates the whole shared directory.
+    for (key, _) in scan_existing_blocks(root) {
+        let _ = std::fs::remove_file(root.join(key.cache_relative_path()));
+    }
+    cleanup_temporary_files(root)?;
+    finish_usage(root, existing_usage(root))
+}
+
+fn existing_usage(root: &Path) -> u64 {
+    scan_existing_blocks(root)
+        .iter()
+        .fold(0u64, |used, (_, encoded_size)| {
+            used.saturating_add(*encoded_size)
+        })
+}
+
+fn clean_usage(root: &Path, max_size: u64) -> Option<u64> {
+    if root.join("dirty").try_exists().ok()? {
+        return None;
+    }
+    let used = std::fs::read_to_string(root.join("usage"))
+        .ok()?
+        .parse::<u64>()
+        .ok()?;
+    (used <= max_size).then_some(used)
+}
+
+fn recover_usage(root: &Path, limit: u64, high_priority_ratio: f64) -> 
io::Result<u64> {
+    // Publication holds the directory lock, so every temporary block found
+    // here belongs to an interrupted publisher. Remove it before clearing 
dirty.
+    cleanup_temporary_files(root)?;
+    #[cfg(test)]
+    RECOVERY_SCANS.with(|count| count.set(count.get() + 1));
+    let mut state = CacheState::new(Some(limit), high_priority_ratio);
+    for (key, encoded_size) in scan_existing_blocks(root) {
+        insert_state_entry(&mut state, key, encoded_size);
+    }
+    for key in collect_evictions(&mut state) {
+        std::fs::remove_file(root.join(key.cache_relative_path()))?;
+    }
+    Ok(state.current_size)
+}
+
+#[cfg(test)]
+thread_local! {
+    static RECOVERY_SCANS: std::cell::Cell<usize> = const { 
std::cell::Cell::new(0) };
+}
+
+fn finish_usage(root: &Path, used: u64) -> io::Result<()> {
+    std::fs::write(root.join("usage"), used.to_string())?;
+    match std::fs::remove_file(root.join("dirty")) {
+        Ok(()) => Ok(()),
+        Err(e) if e.kind() == io::ErrorKind::NotFound => Ok(()),
+        Err(e) => Err(e),
+    }
+}
+
+fn generation_key(key: &BlockKey, generation: &str) -> BlockKey {
+    BlockKey::with_namespace(
+        format!("{}:{generation}", key.namespace),
+        &key.path,
+        key.block_size,
+        key.block_index,
+    )
+}
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+    use crate::io::FileRead;
+    use std::process::{Command, Stdio};
+
+    const CHILD: &str = "io::cache::disk::shared::tests::shared_cache_child";
+
+    #[derive(Debug)]
+    struct Source(PathBuf);
+
+    #[async_trait::async_trait]
+    impl crate::io::FileRead for Source {
+        async fn read(&self, range: std::ops::Range<u64>) -> 
crate::Result<Bytes> {
+            OpenOptions::new()
+                .create(true)
+                .append(true)
+                .open(&self.0)
+                .unwrap()
+                .write_all(b"read\n")
+                .unwrap();
+            tokio::time::sleep(Duration::from_millis(50)).await;
+            Ok(Bytes::from_static(b"payload").slice(range.start as 
usize..range.end as usize))
+        }
+    }
+
+    fn key() -> BlockKey {
+        BlockKey::with_namespace("catalog", "data.parquet", 64, 0)
+    }
+
+    fn child(root: &Path, mode: &str) -> Command {
+        let mut command = Command::new(std::env::current_exe().unwrap());
+        command
+            .args(["--exact", CHILD, "--nocapture"])
+            .env("PAIMON_SHARED_TEST_ROOT", root)
+            .env("PAIMON_SHARED_TEST_MODE", mode)
+            .stdin(Stdio::null());
+        command
+    }
+
+    // A separate executable process, not another cache instance in this 
process.
+    #[tokio::test]
+    async fn shared_cache_child() {
+        let Some(root) = std::env::var_os("PAIMON_SHARED_TEST_ROOT") else {
+            return;
+        };
+        let root = PathBuf::from(root);
+        let mode = std::env::var("PAIMON_SHARED_TEST_MODE").unwrap();
+        if mode == "startup" {
+            SharedDiskCache::new(root, 1024 * 1024).unwrap();
+            assert_eq!(RECOVERY_SCANS.with(|count| count.get()), 0);
+            return;
+        }
+        let cache = SharedDiskCache::new(root.clone(), 4096).unwrap();
+        let token = cache.generation();
+        match std::env::var("PAIMON_SHARED_TEST_MODE").unwrap().as_str() {
+            "populate" => {
+                let _guard = cache.load_lock(&key()).await.unwrap();
+                if cache.get(&key(), &token).await.is_none() {
+                    let mut source = OpenOptions::new()
+                        .create(true)
+                        .append(true)
+                        .open(root.join("source-reads"))
+                        .unwrap();
+                    source.write_all(b"read\n").unwrap();
+                    tokio::time::sleep(Duration::from_millis(50)).await;
+                    cache
+                        .put(&key(), Bytes::from_static(b"payload"), &token)
+                        .await;
+                }
+                assert_eq!(cache.get(&key(), &token).await.unwrap(), 
"payload");
+            }
+            "hold" => {
+                let _guard = cache.load_lock(&key()).await.unwrap();
+                std::fs::write(root.join("locked"), b"ready").unwrap();
+                std::future::pending::<()>().await;
+            }
+            "invalidate" => cache.invalidate().await.unwrap(),
+            "reader" => {
+                use crate::io::cache::{CachedFileReader, FileType, LocalCache, 
LocalCacheConfig};
+                let local = Arc::new(
+                    LocalCache::new(LocalCacheConfig {
+                        shared: true,
+                        dir: Some(root.clone()),
+                        namespace: "catalog".into(),
+                        max_size: Some(4096),
+                        block_size: 64,
+                        whitelist: 
std::collections::HashSet::from([FileType::StructuredData]),
+                    })
+                    .unwrap(),
+                );
+                let reader = CachedFileReader::new(
+                    Arc::new(Source(root.join("source-reads"))),
+                    "s3://bucket/table/data.parquet",
+                    7,
+                    local,
+                );
+                assert_eq!(reader.read(1..4).await.unwrap(), "ayl");
+                assert_eq!(reader.read_full().await.unwrap(), "payload");
+            }
+            other => panic!("Unknown test mode: {other}"),
+        }
+    }
+
+    #[test]
+    fn processes_coalesce_downloads() {
+        let directory = tempfile::tempdir().unwrap();
+        let mut children: Vec<_> = (0..4)
+            .map(|_| child(directory.path(), "populate").spawn().unwrap())
+            .collect();
+        for process in &mut children {
+            assert!(process.wait().unwrap().success());
+        }
+        assert_eq!(
+            
std::fs::read_to_string(directory.path().join("source-reads")).unwrap(),
+            "read\n"
+        );
+    }
+
+    #[test]
+    fn cached_file_reader_reuses_blocks_across_processes() {
+        let directory = tempfile::tempdir().unwrap();
+        let mut children: Vec<_> = (0..4)
+            .map(|_| child(directory.path(), "reader").spawn().unwrap())
+            .collect();
+        for process in &mut children {
+            assert!(process.wait().unwrap().success());
+        }
+        assert_eq!(
+            
std::fs::read_to_string(directory.path().join("source-reads")).unwrap(),
+            "read\n"
+        );
+    }
+
+    #[tokio::test]
+    async fn another_process_invalidates_old_tokens() {
+        let directory = tempfile::tempdir().unwrap();
+        let cache = SharedDiskCache::new(directory.path().to_path_buf(), 
4096).unwrap();
+        let old = cache.generation();
+        cache.put(&key(), Bytes::from_static(b"old"), &old).await;
+        assert!(child(directory.path(), "invalidate")
+            .status()
+            .unwrap()
+            .success());
+        cache.put(&key(), Bytes::from_static(b"late"), &old).await;
+        assert!(cache.get(&key(), &old).await.is_none());
+        assert!(cache.get(&key(), &cache.generation()).await.is_none());
+    }
+
+    #[tokio::test]
+    async fn mutation_does_not_issue_publishable_generation() {
+        let directory = tempfile::tempdir().unwrap();
+        let cache = SharedDiskCache::new(directory.path().to_path_buf(), 
4096).unwrap();
+        let before = cache.generation();
+
+        let mutation = cache.begin_invalidation().await.unwrap();
+        let during = cache.generation();
+        assert!(during.is_none());
+        mutation.finish().await;
+
+        cache.put(&key(), Bytes::from_static(b"old"), &before).await;
+        cache.put(&key(), Bytes::from_static(b"old"), &during).await;
+        let after = cache.generation();
+        assert_ne!(after, before);
+        assert!(cache.get(&key(), &after).await.is_none());
+        cache.put(&key(), Bytes::from_static(b"new"), &after).await;
+        assert_eq!(cache.get(&key(), &after).await.unwrap(), "new");
+
+        let cancelled = cache.begin_invalidation().await.unwrap();
+        drop(cancelled);
+        assert!(cache.generation().is_none());
+        let uncertain = read_generation(directory.path());
+        cache
+            .put(&key(), Bytes::from_static(b"stale"), &uncertain)
+            .await;
+        assert!(cache.generation().is_none());
+        assert!(cache.get(&key(), &uncertain).await.is_none());
+        let reopened = SharedDiskCache::new(directory.path().to_path_buf(), 
4096).unwrap();
+        assert!(reopened.generation().is_none());
+        reopened.begin_invalidation().await.unwrap().finish().await;
+        assert!(reopened.generation().is_none());
+    }
+
+    #[tokio::test]
+    async fn cache_hits_can_share_the_directory_read_lock() {
+        let directory = tempfile::tempdir().unwrap();
+        let cache = SharedDiskCache::new(directory.path().to_path_buf(), 
4096).unwrap();
+        let token = cache.generation();
+        cache
+            .put(&key(), Bytes::from_static(b"payload"), &token)
+            .await;
+        let gate = 
open_lock(&directory.path().join("directory.lock")).unwrap();
+        File::lock_shared(&gate).unwrap();
+
+        let payload = tokio::time::timeout(Duration::from_millis(500), 
cache.get(&key(), &token))
+            .await
+            .expect("cache hits must not serialize behind another reader");
+        assert_eq!(payload.unwrap(), "payload");
+    }
+
+    #[tokio::test]
+    async fn killed_downloader_releases_lock() {
+        let directory = tempfile::tempdir().unwrap();
+        let cache = SharedDiskCache::new(directory.path().to_path_buf(), 
4096).unwrap();
+        let mut process = child(directory.path(), "hold").spawn().unwrap();
+        let ready = tokio::time::timeout(Duration::from_secs(10), async {
+            while !directory.path().join("locked").exists() {
+                tokio::time::sleep(Duration::from_millis(10)).await;
+            }
+        })
+        .await;
+        process.kill().unwrap();
+        process.wait().unwrap();
+        ready.unwrap();
+        let guard = tokio::time::timeout(Duration::from_secs(2), 
cache.load_lock(&key()))
+            .await
+            .unwrap();
+        assert!(guard.is_some());
+    }
+
+    #[tokio::test]
+    async fn capacity_and_corruption() {
+        let directory = tempfile::tempdir().unwrap();
+        let cache = SharedDiskCache::new(directory.path().to_path_buf(), 
512).unwrap();
+        assert!(SharedDiskCache::new(directory.path().to_path_buf(), 
1024).is_err());
+        let token = cache.generation();
+        for index in 0..10 {
+            let mut key = key();
+            key.block_index = index;
+            cache.put(&key, Bytes::from(vec![42; 64]), &token).await;
+        }
+        assert!(existing_usage(directory.path()) <= 512);
+        let newest = BlockKey::with_namespace("catalog", "data.parquet", 64, 
9);
+        let file = directory
+            .path()
+            .join(generation_key(&newest, 
token.as_ref().unwrap()).cache_relative_path());
+        std::fs::write(file, b"corrupt").unwrap();
+        assert!(cache.get(&newest, &token).await.is_none());
+    }
+
+    #[tokio::test]
+    async fn shared_cache_preserves_metadata_priority() {
+        let directory = tempfile::tempdir().unwrap();
+        let cache =
+            SharedDiskCache::new_with_ratio(directory.path().to_path_buf(), 
2048, 0.25).unwrap();
+        assert!(
+            SharedDiskCache::new_with_ratio(directory.path().to_path_buf(), 
2048, 0.5).is_err()
+        );
+        let token = cache.generation();
+        let metadata = BlockKey::with_namespace("catalog", "snapshot-1", 64, 
0);
+        cache.put(&metadata, Bytes::from(vec![1; 64]), &token).await;
+        for index in 0..20 {
+            let mut data = key();
+            data.block_index = index;
+            cache.put(&data, Bytes::from(vec![2; 64]), &token).await;
+        }
+        assert_eq!(
+            cache.get(&metadata, &token).await,
+            Some(Bytes::from(vec![1; 64]))
+        );
+        assert!(existing_usage(directory.path()) <= 2048);
+    }
+
+    #[tokio::test]
+    async fn interrupted_accounting_recovers_before_next_publication() {
+        let directory = tempfile::tempdir().unwrap();
+        let cache = SharedDiskCache::new(directory.path().to_path_buf(), 
512).unwrap();
+        let token = cache.generation();
+        cache.put(&key(), Bytes::from(vec![42; 64]), &token).await;
+        // Simulate exit after publishing a block, before committing its size.
+        std::fs::write(directory.path().join("usage"), b"0").unwrap();
+        std::fs::write(directory.path().join("dirty"), b"").unwrap();
+        let mut second = key();
+        second.block_index = 1;
+        cache.put(&second, Bytes::from(vec![43; 64]), &token).await;
+        assert!(!directory.path().join("dirty").exists());
+        let used: u64 = std::fs::read_to_string(directory.path().join("usage"))
+            .unwrap()
+            .parse()
+            .unwrap();
+        assert_eq!(used, existing_usage(directory.path()));
+        assert!(used <= 512);
+    }
+
+    #[tokio::test]
+    async fn interrupted_temporary_blocks_are_removed_before_clean_startup() {
+        for invalidate in [false, true] {
+            let directory = tempfile::tempdir().unwrap();
+            let cache = SharedDiskCache::new(directory.path().to_path_buf(), 
1024 * 1024).unwrap();
+            let token = cache.generation();
+            let block = directory
+                .path()
+                .join(generation_key(&key(), 
token.as_ref().unwrap()).cache_relative_path());
+            std::fs::create_dir_all(block.parent().unwrap()).unwrap();
+            let temporary = block.parent().unwrap().join(format!(
+                ".{}.tmp.{}",
+                block.file_name().unwrap().to_string_lossy(),
+                uuid::Uuid::new_v4()
+            ));
+            // Simulate a publisher exiting after writing a partial temporary 
block.
+            std::fs::write(&temporary, b"partial block").unwrap();
+            std::fs::write(directory.path().join("dirty"), b"").unwrap();
+            if invalidate {
+                cache.invalidate().await.unwrap();
+            } else {
+                cache
+                    .put(&key(), Bytes::from_static(b"payload"), &token)
+                    .await;
+                assert_eq!(cache.get(&key(), &token).await.unwrap(), 
"payload");
+            }
+            assert!(!temporary.exists());
+            assert!(clean_usage(directory.path(), 1024 * 1024).is_some());
+            assert!(child(directory.path(), "startup")
+                .status()
+                .unwrap()
+                .success());
+            assert!(!temporary.exists());
+        }
+    }
+
+    #[test]
+    fn clean_workers_do_not_scan_blocks_on_startup() {
+        let directory = tempfile::tempdir().unwrap();
+        let cache = SharedDiskCache::new(directory.path().to_path_buf(), 1024 
* 1024).unwrap();
+        let generation = cache.generation().unwrap();
+        let mut used = 0;
+        for index in 0..2000 {
+            let key = generation_key(
+                &BlockKey::with_namespace("catalog", "data.parquet", 64, 
index),
+                &generation,
+            );
+            let path = directory.path().join(key.cache_relative_path());
+            std::fs::create_dir_all(path.parent().unwrap()).unwrap();
+            let bytes = encode_block(&key, &Bytes::from_static(b"payload"));
+            used += bytes.len() as u64;
+            std::fs::write(path, bytes).unwrap();
+        }
+        finish_usage(directory.path(), used).unwrap();
+        let mut children: Vec<_> = (0..8)
+            .map(|_| child(directory.path(), "startup").spawn().unwrap())
+            .collect();
+        for process in &mut children {
+            assert!(process.wait().unwrap().success());
+        }
+    }
+
+    #[test]
+    fn startup_recovers_dirty_invalid_and_oversized_accounting() {
+        let directory = tempfile::tempdir().unwrap();
+        SharedDiskCache::new(directory.path().to_path_buf(), 4096).unwrap();
+        for usage in ["invalid", "4097", "0"] {
+            std::fs::write(directory.path().join("usage"), usage).unwrap();
+            if usage == "0" {
+                std::fs::write(directory.path().join("dirty"), b"").unwrap();
+            }
+            let before = RECOVERY_SCANS.with(|count| count.get());
+            SharedDiskCache::new(directory.path().to_path_buf(), 
4096).unwrap();
+            assert_eq!(RECOVERY_SCANS.with(|count| count.get()), before + 1);
+            assert_eq!(clean_usage(directory.path(), 4096), Some(0));
+        }
+    }
+
+    #[tokio::test]
+    async fn startup_replaces_malformed_generation_and_unknown_blocks() {
+        let directory = tempfile::tempdir().unwrap();
+        let cache = SharedDiskCache::new(directory.path().to_path_buf(), 
4096).unwrap();
+        let old_generation = cache.generation();
+        cache
+            .put(&key(), Bytes::from_static(b"old"), &old_generation)
+            .await;
+        let old_block = directory
+            .path()
+            .join(generation_key(&key(), 
old_generation.as_ref().unwrap()).cache_relative_path());
+        assert!(old_block.exists());
+        std::fs::write(directory.path().join("generation"), 
b"truncated").unwrap();
+
+        let repaired = SharedDiskCache::new(directory.path().to_path_buf(), 
4096).unwrap();
+        let new_generation = repaired.generation();
+        assert!(new_generation.is_some());
+        assert_ne!(new_generation, old_generation);
+        assert!(!old_block.exists());
+        assert_eq!(clean_usage(directory.path(), 4096), Some(0));
+
+        repaired
+            .put(&key(), Bytes::from_static(b"new"), &new_generation)
+            .await;
+        assert_eq!(repaired.get(&key(), &new_generation).await.unwrap(), 
"new");
+    }
+
+    #[tokio::test]
+    async fn invalidation_reports_lock_timeout_and_filesystem_failure() {
+        let directory = tempfile::tempdir().unwrap();
+        let cache = SharedDiskCache::new(directory.path().to_path_buf(), 
4096).unwrap();
+        let gate = 
open_lock(&directory.path().join("directory.lock")).unwrap();
+        gate.lock().unwrap();
+        assert!(cache.invalidate().await.is_err());
+        drop(gate);
+        std::fs::create_dir(directory.path().join("generation.tmp")).unwrap();
+        assert!(cache.invalidate().await.is_err());
+        std::fs::remove_dir(directory.path().join("generation.tmp")).unwrap();
+        cache.invalidate().await.unwrap();
+    }
+}
diff --git a/crates/paimon/src/io/cache/state.rs 
b/crates/paimon/src/io/cache/state.rs
index 7625186b..363bfdc7 100644
--- a/crates/paimon/src/io/cache/state.rs
+++ b/crates/paimon/src/io/cache/state.rs
@@ -94,6 +94,7 @@ struct PathCacheState {
 
 #[derive(Clone)]
 pub(in crate::io) struct CacheReadToken {
+    pub(super) shared_generation: Option<String>,
     generation: u64,
     state: Arc<PathCacheState>,
 }
@@ -134,6 +135,7 @@ impl CacheCoordinator {
     pub(super) fn read_token(&self, namespace: &str, path: &str) -> 
CacheReadToken {
         let state = self.path_state(namespace, path);
         CacheReadToken {
+            shared_generation: None,
             generation: 
state.generation.load(std::sync::atomic::Ordering::SeqCst),
             state,
         }
diff --git a/crates/paimon/src/io/file_io.rs b/crates/paimon/src/io/file_io.rs
index 41cc2adb..ab093427 100644
--- a/crates/paimon/src/io/file_io.rs
+++ b/crates/paimon/src/io/file_io.rs
@@ -655,7 +655,7 @@ impl FileIO {
             self.cache.as_ref().filter(|cache| cache.is_cacheable(path)),
             cache_path,
         ) {
-            cache.invalidate_path(&cache_path).await;
+            cache.invalidate_path(&cache_path).await?;
         }
 
         Ok(())
@@ -681,7 +681,7 @@ impl FileIO {
                 )
             })?;
         if let (Some(cache), Some(cache_path)) = (&self.cache, cache_path) {
-            cache.invalidate_prefix(&cache_path).await;
+            cache.invalidate_prefix(&cache_path).await?;
         }
 
         Ok(())
@@ -759,7 +759,16 @@ impl FileIO {
                     .to_string(),
             });
         }
-        op_src
+        let shared_invalidation = if cache_path_src.is_some() || 
cache_path_dst.is_some() {
+            match &self.cache {
+                Some(cache) => cache.begin_shared_invalidation().await?,
+                None => None,
+            }
+        } else {
+            None
+        };
+        let shared_cache_fenced = shared_invalidation.is_some();
+        let rename_result = op_src
             .rename(relative_path_src.as_ref(), relative_path_dst.as_ref())
             .await
             .map_err(|error| {
@@ -767,13 +776,21 @@ impl FileIO {
                     error,
                     format!("Failed to rename '{src}' to '{dst}'"),
                 )
-            })?;
-        if let Some(cache) = &self.cache {
-            if let Some(cache_path) = cache_path_src {
-                cache.invalidate_prefix(&cache_path).await;
-            }
-            if let Some(cache_path) = cache_path_dst {
-                cache.invalidate_prefix(&cache_path).await;
+            });
+        // A failed rename may still complete remotely. Leave the shared cache
+        // fenced rather than publish data read while its outcome is uncertain.
+        rename_result?;
+        if let Some(invalidation) = shared_invalidation {
+            invalidation.finish().await;
+        }
+        if !shared_cache_fenced {
+            if let Some(cache) = &self.cache {
+                if let Some(cache_path) = cache_path_src {
+                    cache.invalidate_prefix(&cache_path).await?;
+                }
+                if let Some(cache_path) = cache_path_dst {
+                    cache.invalidate_prefix(&cache_path).await?;
+                }
             }
         }
 
@@ -1132,6 +1149,7 @@ struct CacheInvalidatingWriter {
     delegate: Box<dyn FileWrite>,
     cache: Arc<LocalCache>,
     path: String,
+    delegate_closed: bool,
 }
 
 /// OpenDAL's local position writer retains its last write until close. Use a
@@ -1245,13 +1263,16 @@ impl FileWrite for CacheInvalidatingWriter {
 
     async fn flush(&mut self) -> crate::Result<()> {
         self.delegate.flush().await?;
-        self.cache.invalidate_path(&self.path).await;
+        self.cache.invalidate_path(&self.path).await?;
         Ok(())
     }
 
     async fn close(&mut self) -> crate::Result<()> {
-        self.delegate.close().await?;
-        self.cache.invalidate_path(&self.path).await;
+        if !self.delegate_closed {
+            self.delegate.close().await?;
+            self.delegate_closed = true;
+        }
+        self.cache.invalidate_path(&self.path).await?;
         Ok(())
     }
 }
@@ -1266,7 +1287,7 @@ struct CacheInvalidatingAsyncWriter {
     cache: Arc<LocalCache>,
     path: String,
     delegate_shutdown: bool,
-    invalidation: Option<Pin<Box<dyn Future<Output = ()> + Send>>>,
+    invalidation: Option<Pin<Box<dyn Future<Output = crate::Result<()>> + 
Send>>>,
 }
 
 impl tokio::io::AsyncWrite for CacheInvalidatingAsyncWriter {
@@ -1306,7 +1327,16 @@ impl tokio::io::AsyncWrite for 
CacheInvalidatingAsyncWriter {
         if let Some(invalidation) = &mut self.invalidation {
             match invalidation.as_mut().poll(context) {
                 Poll::Pending => return Poll::Pending,
-                Poll::Ready(()) => self.invalidation = None,
+                Poll::Ready(Ok(())) => self.invalidation = None,
+                Poll::Ready(Err(error)) => {
+                    // A retry must retry invalidation, not report successful
+                    // shutdown merely because the delegate is already closed.
+                    let cache = self.cache.clone();
+                    let path = self.path.clone();
+                    self.invalidation =
+                        Some(Box::pin(async move { 
cache.invalidate_path(&path).await }));
+                    return Poll::Ready(Err(std::io::Error::other(error)));
+                }
             }
         }
         Poll::Ready(Ok(()))
@@ -1514,6 +1544,7 @@ impl OutputFile {
             delegate: writer,
             cache: cache.clone(),
             path: cache_path,
+            delegate_closed: false,
         })
     }
 
@@ -3022,6 +3053,272 @@ mod input_output_test {
         );
     }
 
+    #[cfg(not(windows))]
+    #[tokio::test]
+    async fn test_shared_cache_delete_timeout_and_recreate() {
+        use tokio::io::AsyncWriteExt;
+        let source = tempfile::tempdir().unwrap();
+        let directory = tempfile::tempdir().unwrap();
+        let path = source.path().join("snapshot-1");
+        std::fs::write(&path, b"old metadata").unwrap();
+        let location = format!("file:{}", path.display());
+        let mut options = Options::new();
+        options.set(CatalogOptions::LOCAL_CACHE_ENABLED, "true");
+        options.set(CatalogOptions::LOCAL_CACHE_SHARED, "true");
+        options.set(
+            CatalogOptions::LOCAL_CACHE_DIR,
+            directory.path().to_string_lossy(),
+        );
+        options.set(CatalogOptions::LOCAL_CACHE_MAX_SIZE, "1 MiB");
+        let cache = Arc::new(
+            
LocalCache::new(LocalCacheConfig::from_options(&options).unwrap().unwrap()).unwrap(),
+        );
+        let file_io = FileIOBuilder::new("file")
+            .with_local_cache(cache)
+            .build()
+            .unwrap();
+        assert_eq!(
+            file_io.new_input(&location).unwrap().read().await.unwrap(),
+            "old metadata"
+        );
+        let gate = std::fs::OpenOptions::new()
+            .read(true)
+            .write(true)
+            .open(
+                directory
+                    .path()
+                    .join("paimon-shared-local-cache-v1/directory.lock"),
+            )
+            .unwrap();
+        gate.lock().unwrap();
+        let error = file_io.delete_file(&location).await.unwrap_err();
+        assert!(error.to_string().contains("invalidation failed"));
+        drop(gate);
+        // The underlying delete may already have succeeded. Retrying the
+        // idempotent delete must complete invalidation before path reuse.
+        file_io.delete_file(&location).await.unwrap();
+        std::fs::write(&path, b"new metadata").unwrap();
+        assert_eq!(
+            file_io.new_input(&location).unwrap().read().await.unwrap(),
+            "new metadata"
+        );
+        let mut writer = file_io
+            .new_output(&location)
+            .unwrap()
+            .async_writer()
+            .await
+            .unwrap();
+        writer.write_all(b"replacement!").await.unwrap();
+        let gate = std::fs::OpenOptions::new()
+            .read(true)
+            .write(true)
+            .open(
+                directory
+                    .path()
+                    .join("paimon-shared-local-cache-v1/directory.lock"),
+            )
+            .unwrap();
+        gate.lock().unwrap();
+        assert!(writer.shutdown().await.is_err());
+        // A second shutdown cannot silently succeed while invalidation still 
fails.
+        assert!(writer.shutdown().await.is_err());
+        drop(gate);
+        writer.shutdown().await.unwrap();
+        assert_eq!(
+            file_io.new_input(&location).unwrap().read().await.unwrap(),
+            "replacement!"
+        );
+
+        let gate = std::fs::OpenOptions::new()
+            .read(true)
+            .write(true)
+            .open(
+                directory
+                    .path()
+                    .join("paimon-shared-local-cache-v1/directory.lock"),
+            )
+            .unwrap();
+        gate.lock().unwrap();
+        let directory_location = format!("file:{}", source.path().display());
+        assert!(file_io.delete_dir(&directory_location).await.is_err());
+        drop(gate);
+        file_io.delete_dir(&directory_location).await.unwrap();
+        std::fs::create_dir_all(source.path()).unwrap();
+        std::fs::write(&path, b"recreated!!!").unwrap();
+        assert_eq!(
+            file_io.new_input(&location).unwrap().read().await.unwrap(),
+            "recreated!!!"
+        );
+    }
+
+    #[cfg(not(windows))]
+    #[tokio::test]
+    async fn test_shared_cache_regular_close_retries_invalidation() {
+        let source = tempfile::tempdir().unwrap();
+        let directory = tempfile::tempdir().unwrap();
+        let path = source.path().join("snapshot-1");
+        std::fs::write(&path, b"old").unwrap();
+        let location = format!("file:{}", path.display());
+        let mut options = Options::new();
+        options.set(CatalogOptions::LOCAL_CACHE_ENABLED, "true");
+        options.set(CatalogOptions::LOCAL_CACHE_SHARED, "true");
+        options.set(
+            CatalogOptions::LOCAL_CACHE_DIR,
+            directory.path().to_string_lossy(),
+        );
+        options.set(CatalogOptions::LOCAL_CACHE_MAX_SIZE, "1 MiB");
+        let cache = Arc::new(
+            
LocalCache::new(LocalCacheConfig::from_options(&options).unwrap().unwrap()).unwrap(),
+        );
+        let file_io = FileIOBuilder::new("file")
+            .with_local_cache(cache)
+            .build()
+            .unwrap();
+        assert_eq!(
+            file_io.new_input(&location).unwrap().read().await.unwrap(),
+            "old"
+        );
+        let mut writer = file_io
+            .new_output(&location)
+            .unwrap()
+            .writer()
+            .await
+            .unwrap();
+        writer.write(Bytes::from_static(b"new")).await.unwrap();
+        let gate = std::fs::OpenOptions::new()
+            .read(true)
+            .write(true)
+            .open(
+                directory
+                    .path()
+                    .join("paimon-shared-local-cache-v1/directory.lock"),
+            )
+            .unwrap();
+        gate.lock().unwrap();
+        assert!(writer.close().await.is_err());
+        assert!(writer.close().await.is_err());
+        drop(gate);
+        writer.close().await.unwrap();
+        assert_eq!(
+            file_io.new_input(&location).unwrap().read().await.unwrap(),
+            "new"
+        );
+    }
+
+    #[cfg(not(windows))]
+    #[tokio::test]
+    async fn test_shared_cache_rename_waits_for_invalidation_fence() {
+        let source_directory = tempfile::tempdir().unwrap();
+        let cache_directory = tempfile::tempdir().unwrap();
+        let source_path = source_directory.path().join("snapshot-1");
+        let target_path = source_directory.path().join("snapshot-2");
+        std::fs::write(&source_path, b"source value").unwrap();
+        std::fs::write(&target_path, b"target value").unwrap();
+        let source_location = format!("file:{}", source_path.display());
+        let target_location = format!("file:{}", target_path.display());
+        let mut options = Options::new();
+        options.set(CatalogOptions::LOCAL_CACHE_ENABLED, "true");
+        options.set(CatalogOptions::LOCAL_CACHE_SHARED, "true");
+        options.set(
+            CatalogOptions::LOCAL_CACHE_DIR,
+            cache_directory.path().to_string_lossy(),
+        );
+        options.set(CatalogOptions::LOCAL_CACHE_MAX_SIZE, "1 MiB");
+        let cache = Arc::new(
+            
LocalCache::new(LocalCacheConfig::from_options(&options).unwrap().unwrap()).unwrap(),
+        );
+        let file_io = FileIOBuilder::new("file")
+            .with_local_cache(cache)
+            .build()
+            .unwrap();
+        assert_eq!(
+            file_io
+                .new_input(&target_location)
+                .unwrap()
+                .read()
+                .await
+                .unwrap(),
+            "target value"
+        );
+
+        let gate = std::fs::OpenOptions::new()
+            .read(true)
+            .write(true)
+            .open(
+                cache_directory
+                    .path()
+                    .join("paimon-shared-local-cache-v1/directory.lock"),
+            )
+            .unwrap();
+        gate.lock().unwrap();
+        let rename_io = file_io.clone();
+        let rename_source = source_location.clone();
+        let rename_target = target_location.clone();
+        let rename =
+            tokio::spawn(async move { rename_io.rename(&rename_source, 
&rename_target).await });
+        tokio::time::sleep(std::time::Duration::from_millis(100)).await;
+        assert!(!rename.is_finished());
+        assert_eq!(std::fs::read(&source_path).unwrap(), b"source value");
+        assert_eq!(std::fs::read(&target_path).unwrap(), b"target value");
+
+        drop(gate);
+        tokio::time::timeout(std::time::Duration::from_secs(5), rename)
+            .await
+            .unwrap()
+            .unwrap()
+            .unwrap();
+        assert!(!source_path.exists());
+        assert_eq!(std::fs::read(&target_path).unwrap(), b"source value");
+        assert_eq!(
+            file_io
+                .new_input(&target_location)
+                .unwrap()
+                .read()
+                .await
+                .unwrap(),
+            "source value"
+        );
+
+        let missing_source = format!("file:{}", 
source_directory.path().join("missing").display());
+        assert!(file_io
+            .rename(&missing_source, &target_location)
+            .await
+            .is_err());
+        assert!(cache_directory
+            .path()
+            .join("paimon-shared-local-cache-v1/mutation-pending")
+            .exists());
+        std::fs::write(&target_path, b"newer value").unwrap();
+        assert_eq!(
+            file_io
+                .new_input(&target_location)
+                .unwrap()
+                .read()
+                .await
+                .unwrap(),
+            "newer value"
+        );
+        let next_path = source_directory.path().join("snapshot-3");
+        let next_location = format!("file:{}", next_path.display());
+        file_io
+            .rename(&target_location, &next_location)
+            .await
+            .unwrap();
+        assert_eq!(
+            file_io
+                .new_input(&next_location)
+                .unwrap()
+                .read()
+                .await
+                .unwrap(),
+            "newer value"
+        );
+        assert!(cache_directory
+            .path()
+            .join("paimon-shared-local-cache-v1/mutation-pending")
+            .exists());
+    }
+
     #[cfg(not(windows))]
     #[tokio::test]
     async fn test_file_io_local_cache_invalidates_after_delete_directory() {
diff --git a/crates/paimon/src/table/rest_env.rs 
b/crates/paimon/src/table/rest_env.rs
index 9c4ef802..c55bfd70 100644
--- a/crates/paimon/src/table/rest_env.rs
+++ b/crates/paimon/src/table/rest_env.rs
@@ -23,7 +23,7 @@ use crate::api::GetTableResponse;
 use crate::catalog::{FusePathResolver, Identifier, RESTToken, RESTTokenFileIO};
 use crate::common::{CatalogOptions, Options};
 use crate::error::Error;
-use crate::io::cache::{create_local_cache_with_namespace, LocalCache};
+use crate::io::cache::{create_local_cache_with_namespace_async, LocalCache};
 use crate::io::{FileIO, FileIOCacheContext};
 use crate::spec::{CoreOptions, TableSchema, PATH_OPTION};
 use crate::table::snapshot_commit::{RESTSnapshotCommit, SnapshotCommit};
@@ -64,7 +64,8 @@ impl Table {
             .get(CatalogOptions::DATA_TOKEN_ENABLED)
             .map(|v| v.eq_ignore_ascii_case("true"))
             .unwrap_or(false);
-        let local_cache = create_local_cache_with_namespace(&rest_options, 
api.options())?;
+        let local_cache =
+            create_local_cache_with_namespace_async(&rest_options, 
api.options()).await?;
         let file_io_cache = 
FileIOCacheContext::from_props(api.options().to_map())?;
         let fuse_resolver = Arc::new(FusePathResolver::new(api.options()));
         RESTEnv::build_table_with_token(
diff --git a/docs/src/getting-started.md b/docs/src/getting-started.md
index 8c99f8c2..dcfa30e6 100644
--- a/docs/src/getting-started.md
+++ b/docs/src/getting-started.md
@@ -401,6 +401,7 @@ let catalog = CatalogFactory::create(options).await?;
 |--------|---------|-------------|
 | `local-cache.enabled` | `false` | Enable catalog-scoped local block caching. 
|
 | `local-cache.dir` | none | Optional base directory. When set, Paimon uses a 
persistent disk cache in a private versioned child directory; otherwise it uses 
memory. |
+| `local-cache.shared` | `false` | Share disk blocks across processes on one 
node; requires `local-cache.dir` and `local-cache.max-size`. |
 | `local-cache.max-size` | unlimited | Maximum cache size. Memory caches count 
payload bytes; disk caches count encoded bytes. Values accept byte units such 
as `512 MiB` or `20 GiB`. |
 | `local-cache.high-priority-pool-ratio` | `0.25` | Fraction of a finite cache 
budget protected for `meta` and `global-index` blocks. Use `0` for a single 
LRU. |
 | `local-cache.block-size` | `1 MiB` | Block size used for cached range reads. 
|
@@ -411,7 +412,7 @@ blocks without dedicated BLOB, video, or vector-store 
files. `data` and `*`
 retain their broader behavior.
 
 The protected and regular LRUs share one total budget; either can use idle
-capacity. Catalogs sharing a disk cache within one process must use the same 
ratio.
+capacity. Catalogs or processes sharing a disk cache must use the same ratio.
 
 Each catalog owns its in-memory cache for the catalog's lifetime. Disk caches
 are reused after process restarts. Cache keys include a catalog-configuration
@@ -424,8 +425,16 @@ convention. Cache managers using the same canonical 
directory in one process
 share LRU, size accounting, and invalidation state; if their configured limits
 differ, the smallest `local-cache.max-size` is used. Restart recovery runs on a
 blocking worker, reads only block headers and file metadata, and validates
-payload CRC lazily on the first hit. Use a separate `local-cache.dir` for each
-worker or process because processes do not share exact LRU or size accounting.
+payload CRC lazily on the first hit. Without shared mode, use a separate
+`local-cache.dir` per worker or process.
+
+For sharing, set `local-cache.enabled=true` and `local-cache.shared=true` in
+same-UID workers with the same node-local directory and capacity. NFS,
+cross-node sharing, and catalogs inherited across `fork` are unsupported.
+Invalidation clears the shared cache; if it fails after a storage mutation,
+do not reuse the affected path until invalidation succeeds.
+A canceled or failed rename bypasses this cache; after confirming storage 
state,
+use a new cache directory to resume sharing.
 
 ### BLOB Index Cache
 

Reply via email to