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 d2462936 [rust] Replace BLOB index entry limit with a memory budget 
(#998)
d2462936 is described below

commit d246293694af2899732bb403e37d550582d28e01
Author: XiaoHongbo <[email protected]>
AuthorDate: Sat Oct 3 21:34:11 2026 +0800

    [rust] Replace BLOB index entry limit with a memory budget (#998)
---
 Cargo.lock                                         |   1 +
 bindings/c/Cargo.toml                              |   1 +
 bindings/c/src/error.rs                            |  37 +
 crates/paimon/src/arrow/format/blob.rs             | 989 +++++++++++++++++++--
 crates/paimon/src/arrow/format/metadata_cache.rs   |  34 +-
 crates/paimon/src/catalog/filesystem.rs            |  20 +-
 crates/paimon/src/catalog/rest/rest_catalog.rs     |  15 +-
 .../paimon/src/catalog/rest/rest_token_file_io.rs  |  40 +-
 crates/paimon/src/common/options.rs                |   7 +
 crates/paimon/src/error.rs                         |  30 +
 crates/paimon/src/io/file_io.rs                    | 539 +++++++++--
 crates/paimon/src/io/file_io/provider_tests.rs     |  33 +
 crates/paimon/src/io/storage_hdfs.rs               |  42 +-
 crates/paimon/src/table/data_file_reader.rs        |  22 +-
 crates/paimon/src/table/mod.rs                     |   5 +-
 crates/paimon/src/table/rest_env.rs                |  49 +-
 crates/paimon/src/table/snapshot_manager.rs        |   5 +-
 .../paimon/src/table/table_commit/parity_tests.rs  |   5 +-
 .../src/table/table_commit/recovery_tests.rs       |   5 +-
 docs/src/getting-started.md                        |   6 +
 20 files changed, 1676 insertions(+), 209 deletions(-)

diff --git a/Cargo.lock b/Cargo.lock
index d9d01852..3347b5bb 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -4739,6 +4739,7 @@ dependencies = [
  "async-trait",
  "bytes",
  "futures",
+ "opendal-core",
  "paimon",
  "paimon-vindex-core",
  "serde_json",
diff --git a/bindings/c/Cargo.toml b/bindings/c/Cargo.toml
index 916605b7..a62c2c36 100644
--- a/bindings/c/Cargo.toml
+++ b/bindings/c/Cargo.toml
@@ -48,6 +48,7 @@ async-trait = "0.1.81"
 bytes = "1.7.1"
 
 [dev-dependencies]
+opendal = { package = "opendal-core", version = "0.58.0" }
 # Test-only: the vector-search integration tests build a real primary-key 
vindex
 # IVF-flat ANN segment fixture in-process. Versions match crates/paimon.
 paimon-vindex-core = "0.5.0"
diff --git a/bindings/c/src/error.rs b/bindings/c/src/error.rs
index e6fdf426..26322967 100644
--- a/bindings/c/src/error.rs
+++ b/bindings/c/src/error.rs
@@ -65,6 +65,7 @@ impl paimon_error {
             | paimon::Error::DataInvalid { .. }
             | paimon::Error::IdentifierInvalid { .. } => 
PaimonErrorCode::InvalidInput,
             paimon::Error::IoUnexpected { .. } => PaimonErrorCode::IoError,
+            _ if e.is_io_unexpected() => PaimonErrorCode::IoError,
             paimon::Error::ResourceExhausted { .. } => 
PaimonErrorCode::ResourceExhausted,
             _ => PaimonErrorCode::Unexpected,
         };
@@ -123,3 +124,39 @@ pub fn check_non_null<T>(ptr: *const T, name: &str) -> 
Result<(), *mut paimon_er
         Ok(())
     }
 }
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+
+    #[test]
+    fn wrapped_blob_io_failure_keeps_c_io_error_code() {
+        let io_error = || paimon::Error::IoUnexpected {
+            message: "BLOB index read failed".to_string(),
+            source: Box::new(opendal::Error::new(
+                opendal::ErrorKind::Unexpected,
+                "storage failure",
+            )),
+        };
+        let wrapped = paimon::Error::UnexpectedError {
+            message: "shared BLOB index load failed".to_string(),
+            source: Some(Box::new(std::io::Error::other(io_error()))),
+        };
+        for error in [io_error(), wrapped] {
+            let error = paimon_error::from_paimon(error);
+            unsafe {
+                assert_eq!((*error).code, PaimonErrorCode::IoError as i32);
+                paimon_error_free(error);
+            }
+        }
+
+        let other = paimon_error::from_paimon(paimon::Error::UnexpectedError {
+            message: "not an I/O error".to_string(),
+            source: Some(Box::new(std::io::Error::other("unrelated"))),
+        });
+        unsafe {
+            assert_eq!((*other).code, PaimonErrorCode::Unexpected as i32);
+            paimon_error_free(other);
+        }
+    }
+}
diff --git a/crates/paimon/src/arrow/format/blob.rs 
b/crates/paimon/src/arrow/format/blob.rs
index fff61b82..c1da6905 100644
--- a/crates/paimon/src/arrow/format/blob.rs
+++ b/crates/paimon/src/arrow/format/blob.rs
@@ -15,9 +15,10 @@
 // specific language governing permissions and limitations
 // under the License.
 
+use super::metadata_cache::FileMetadataCache;
 use super::{FilePredicates, FormatFileReader, FormatFileWriter, 
FormatWriteResult};
 use crate::arrow::build_target_arrow_schema;
-use crate::io::{FileRead, FileWrite};
+use crate::io::{BlobIndexCacheContext, FileRead, FileWrite};
 use crate::spec::{BlobDescriptor, DataField, DataType};
 use crate::table::{ArrowRecordBatchStream, RowRange};
 use crate::Error;
@@ -33,7 +34,6 @@ use async_stream::try_stream;
 use async_trait::async_trait;
 use bytes::Bytes;
 use futures::{StreamExt, TryStreamExt};
-use lru::LruCache;
 use std::ops::Range;
 use std::sync::Arc;
 
@@ -93,7 +93,7 @@ impl IndexedBlobReader {
         blob_parallelism: usize,
     ) -> crate::Result<Self> {
         debug_assert!(blob_parallelism > 0);
-        let index = BlobFileIndex::load_cached(reader.as_ref(), file_size, 
&file_path).await?;
+        let index = BlobFileIndex::load_cached(reader.as_ref(), 
file_size).await?;
         Ok(Self {
             reader,
             index,
@@ -163,20 +163,9 @@ pub(crate) enum BlobReadValue {
 
 const BLOB_FOOTER_SIZE: u64 = 5;
 const BLOB_FORMAT_VERSION: u8 = 1;
-const BLOB_INDEX_CACHE_CAPACITY: usize = 16;
-#[derive(Clone, Debug, Eq, Hash, PartialEq)]
-struct BlobIndexCacheKey {
-    namespace: usize,
-    file_path: String,
-}
-
-static BLOB_INDEX_CACHE: std::sync::LazyLock<
-    std::sync::Mutex<LruCache<BlobIndexCacheKey, Arc<BlobFileIndex>>>,
-> = std::sync::LazyLock::new(|| {
-    std::sync::Mutex::new(LruCache::new(
-        std::num::NonZeroUsize::new(BLOB_INDEX_CACHE_CAPACITY).unwrap(),
-    ))
-});
+const BLOB_INDEX_CACHE_CONTAINER_OVERHEAD: usize = 
std::mem::size_of::<String>()
+    + std::mem::size_of::<Arc<BlobFileIndex>>()
+    + 4 * std::mem::size_of::<usize>();
 const BLOB_MAGIC_NUMBER: i32 = 1481511375;
 const BLOB_MAGIC_NUMBER_BYTES: [u8; 4] = BLOB_MAGIC_NUMBER.to_le_bytes();
 const BLOB_INLINE_HEADER_SIZE: u64 = 4;
@@ -1688,36 +1677,71 @@ async fn read_blob_range(reader: &dyn FileRead, range: 
Range<u64>) -> crate::Res
     Ok(bytes)
 }
 
-impl BlobFileIndex {
-    async fn load_cached(
-        reader: &dyn FileRead,
-        file_size: u64,
-        file_path: &str,
-    ) -> crate::Result<Arc<Self>> {
-        let cache_key = reader
-            .cache_namespace()
-            .filter(|_| !file_path.is_empty())
-            .map(|namespace| BlobIndexCacheKey {
-                namespace,
-                file_path: file_path.to_string(),
-            });
-        if let Some(cache_key) = &cache_key {
-            let mut cache = BLOB_INDEX_CACHE
-                .lock()
-                .unwrap_or_else(|error| error.into_inner());
-            if let Some(index) = cache.get(cache_key) {
-                return Ok(index.clone());
-            }
+type BlobIndexLoadResult = Result<Arc<BlobFileIndex>, Arc<Error>>;
+
+struct BlobIndexCache {
+    entries: FileMetadataCache<String, BlobIndexLoadResult>,
+}
+
+impl BlobIndexCache {
+    fn new(max_bytes: usize) -> Self {
+        Self {
+            entries: FileMetadataCache::new(max_bytes, usize::MAX),
         }
+    }
+}
 
-        let index = Arc::new(Self::load(reader, file_size).await?);
-        if let Some(cache_key) = cache_key {
-            BLOB_INDEX_CACHE
-                .lock()
-                .unwrap_or_else(|error| error.into_inner())
-                .put(cache_key, index.clone());
+impl BlobFileIndex {
+    async fn load_cached(reader: &dyn FileRead, file_size: u64) -> 
crate::Result<Arc<Self>> {
+        let Some(context) = reader
+            .blob_index_cache()
+            .and_then(|cache| cache.downcast_ref::<BlobIndexCacheContext>())
+        else {
+            return Ok(Arc::new(Self::load(reader, file_size).await?));
+        };
+        if !context.belongs_to_current_process() {
+            return Ok(Arc::new(Self::load(reader, file_size).await?));
+        }
+        let cache = context.get_or_init(BlobIndexCache::new);
+        let cache_key = reader.cache_key().map(ToOwned::to_owned);
+        let key_heap_bytes = cache_key.as_ref().map_or(0, String::capacity);
+        let load = cache
+            .entries
+            .get_or_try_insert_with_admission(
+                cache_key,
+                key_heap_bytes,
+                || async {
+                    Ok::<_, std::convert::Infallible>(Arc::new(
+                        Self::load(reader, file_size)
+                            .await
+                            .map(Arc::new)
+                            .map_err(Arc::new),
+                    ))
+                },
+                |load| {
+                    load.as_ref()
+                        .ok()
+                        .map(|index| index.estimated_cache_bytes())
+                },
+            )
+            .await
+            .unwrap();
+        match load.as_ref() {
+            Ok(index) => Ok(Arc::clone(index)),
+            Err(error) => Err(clone_blob_index_error(error)),
         }
-        Ok(index)
+    }
+
+    fn estimated_cache_bytes(&self) -> usize {
+        std::mem::size_of::<Self>()
+            .saturating_add(
+                self.entries
+                    .capacity()
+                    .saturating_mul(std::mem::size_of::<BlobEntry>()),
+            )
+            // Account for the LRU node, hash slot and Arc allocation which are
+            // not represented by the decoded index itself.
+            .saturating_add(BLOB_INDEX_CACHE_CONTAINER_OVERHEAD)
     }
 
     async fn load(reader: &dyn FileRead, file_size: u64) -> 
crate::Result<Self> {
@@ -1785,6 +1809,45 @@ impl BlobFileIndex {
     }
 }
 
+#[derive(Debug)]
+struct SharedBlobIndexError(Arc<Error>);
+
+impl std::fmt::Display for SharedBlobIndexError {
+    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
+        std::fmt::Display::fmt(self.0.as_ref(), f)
+    }
+}
+
+impl std::error::Error for SharedBlobIndexError {
+    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
+        Some(self.0.as_ref())
+    }
+}
+
+fn clone_blob_index_error(error: &Arc<Error>) -> Error {
+    let source = || {
+        Some(Box::new(SharedBlobIndexError(Arc::clone(error)))
+            as Box<dyn std::error::Error + Send + Sync>)
+    };
+    match error.as_ref() {
+        Error::DataInvalid { message, .. } => Error::DataInvalid {
+            message: message.clone(),
+            source: source(),
+        },
+        Error::Unsupported { message } => Error::Unsupported {
+            message: message.clone(),
+        },
+        Error::UnexpectedError { message, .. } => Error::UnexpectedError {
+            message: message.clone(),
+            source: source(),
+        },
+        _ => Error::UnexpectedError {
+            message: error.to_string(),
+            source: source(),
+        },
+    }
+}
+
 #[derive(Debug, Clone)]
 enum BlobEntry {
     Value(Range<u64>),
@@ -2333,11 +2396,35 @@ fn encode_varint(value: i64, out: &mut Vec<u8>) {
 mod tests {
     use super::*;
     use crate::btree::test_util::BytesFileRead;
-    use crate::io::{FileIO, FileIOBuilder};
+    use crate::common::CatalogOptions;
+    #[cfg(feature = "storage-oss")]
+    use crate::io::FileIOCacheContext;
+    #[cfg(feature = "storage-oss")]
+    use crate::io::FileIOProvider;
+    use crate::io::{BlobIndexCacheContext, FileIO, FileIOBuilder};
     use crate::spec::{ArrayType, BlobType, MapType, VarCharType};
     use arrow_array::Array;
+    #[cfg(feature = "storage-oss")]
+    use axum::{
+        body::Body,
+        extract::State,
+        http::{
+            header::{CONTENT_LENGTH, CONTENT_RANGE, RANGE},
+            HeaderMap, Response, StatusCode,
+        },
+        routing::get,
+        Router,
+    };
     use bytes::Bytes;
     use futures::TryStreamExt;
+    #[cfg(feature = "storage-oss")]
+    use opendal::{Configurator, HttpTransporter, OperationContext, Operator};
+    #[cfg(feature = "storage-oss")]
+    use opendal_http_transport_reqwest::ReqwestTransport;
+    #[cfg(feature = "storage-oss")]
+    use opendal_service_oss::OssConfig;
+    #[cfg(feature = "storage-oss")]
+    use std::collections::HashMap;
     use std::mem::size_of;
     use std::sync::atomic::{AtomicUsize, Ordering};
     use std::sync::Mutex;
@@ -2387,6 +2474,120 @@ mod tests {
         assert!(matches!(error, Error::ProcessForkUnsupported { .. }));
     }
 
+    #[cfg(target_os = "linux")]
+    struct ForkBoundBlobRead {
+        parent_pid: u32,
+        cache: Arc<BlobIndexCacheContext>,
+        started: Arc<tokio::sync::Notify>,
+    }
+
+    #[cfg(target_os = "linux")]
+    #[async_trait::async_trait]
+    impl FileRead for ForkBoundBlobRead {
+        async fn read(&self, _range: Range<u64>) -> crate::Result<Bytes> {
+            if std::process::id() != self.parent_pid {
+                return Err(Error::ProcessForkUnsupported {
+                    message: crate::error::JINDO_FORK_ERROR.to_string(),
+                });
+            }
+            self.started.notify_one();
+            std::future::pending().await
+        }
+
+        fn cache_key(&self) -> Option<&str> {
+            Some("fork-pending-blob")
+        }
+
+        fn blob_index_cache(&self) -> Option<&(dyn std::any::Any + Send + 
Sync)> {
+            Some(self.cache.as_ref())
+        }
+    }
+
+    #[cfg(target_os = "linux")]
+    #[tokio::test(flavor = "current_thread")]
+    async fn blob_index_cache_does_not_join_parent_load_after_fork() {
+        let cache = blob_index_cache("64 MiB");
+        let started = Arc::new(tokio::sync::Notify::new());
+        let parent_pid = std::process::id();
+        let parent_reader = ForkBoundBlobRead {
+            parent_pid,
+            cache: Arc::clone(&cache),
+            started: Arc::clone(&started),
+        };
+        let parent_load =
+            tokio::spawn(async move { 
BlobFileIndex::load_cached(&parent_reader, 5).await });
+        tokio::time::timeout(Duration::from_secs(2), started.notified())
+            .await
+            .expect("parent load did not start");
+
+        let child_pid = unsafe { libc::fork() };
+        assert!(child_pid >= 0, "fork failed");
+        if child_pid == 0 {
+            let passed = std::thread::spawn(move || {
+                let runtime = tokio::runtime::Builder::new_current_thread()
+                    .enable_all()
+                    .build()
+                    .unwrap();
+                runtime.block_on(async {
+                    let reader = ForkBoundBlobRead {
+                        parent_pid,
+                        cache,
+                        started,
+                    };
+                    matches!(
+                        tokio::time::timeout(
+                            Duration::from_millis(500),
+                            BlobFileIndex::load_cached(&reader, 5),
+                        )
+                        .await,
+                        Ok(Err(Error::ProcessForkUnsupported { .. }))
+                    )
+                })
+            })
+            .join()
+            .unwrap_or(false);
+            unsafe { libc::_exit(i32::from(!passed)) };
+        }
+
+        let (status, timed_out) = tokio::task::spawn_blocking(move || {
+            let deadline = std::time::Instant::now() + Duration::from_secs(3);
+            loop {
+                let mut status = 0;
+                let waited = unsafe { libc::waitpid(child_pid, &mut status, 
libc::WNOHANG) };
+                if waited == child_pid {
+                    return Ok::<_, std::io::Error>((status, false));
+                }
+                if waited < 0 {
+                    let error = std::io::Error::last_os_error();
+                    if error.kind() != std::io::ErrorKind::Interrupted {
+                        return Err(error);
+                    }
+                }
+                if std::time::Instant::now() >= deadline {
+                    unsafe { libc::kill(child_pid, libc::SIGKILL) };
+                    loop {
+                        let waited = unsafe { libc::waitpid(child_pid, &mut 
status, 0) };
+                        if waited == child_pid {
+                            return Ok((status, true));
+                        }
+                        let error = std::io::Error::last_os_error();
+                        if error.kind() != std::io::ErrorKind::Interrupted {
+                            return Err(error);
+                        }
+                    }
+                }
+                std::thread::sleep(Duration::from_millis(10));
+            }
+        })
+        .await
+        .unwrap()
+        .unwrap();
+        parent_load.abort();
+        assert!(!timed_out, "child hung on inherited BLOB cache state");
+        assert!(libc::WIFEXITED(status));
+        assert_eq!(libc::WEXITSTATUS(status), 0);
+    }
+
     #[tokio::test]
     async fn test_index_tail_prefetch_boundaries() {
         for rows in [0, 1, 4090, 4091, 4092, 9000] {
@@ -2630,10 +2831,11 @@ mod tests {
     async fn test_blob_reader_reuses_cached_index() {
         let file_path = "file:///blob-index-cache-test/data.blob";
         let file_bytes = load_blob_fixture("blob-basic.blob");
-        let first =
-            
TrackingFileRead::new(Bytes::from(file_bytes.clone())).with_cache_namespace(usize::MAX);
-        let second =
-            
TrackingFileRead::new(Bytes::from(file_bytes.clone())).with_cache_namespace(usize::MAX);
+        let cache = blob_index_cache("64 MiB");
+        let first = TrackingFileRead::new(Bytes::from(file_bytes.clone()))
+            .with_blob_index_cache(file_path, cache.clone());
+        let second = TrackingFileRead::new(Bytes::from(file_bytes.clone()))
+            .with_blob_index_cache(file_path, cache);
 
         let first_reader = IndexedBlobReader::open(
             Box::new(first.clone()),
@@ -2657,6 +2859,234 @@ mod tests {
         assert!(second.ranges().is_empty());
     }
 
+    #[tokio::test]
+    async fn test_blob_index_cache_can_be_disabled() {
+        let file_path = "file:///blob-index-cache-disabled/data.blob";
+        let file_bytes = load_blob_fixture("blob-basic.blob");
+        let cache = blob_index_cache("0");
+        let first = TrackingFileRead::new(Bytes::from(file_bytes.clone()))
+            .with_blob_index_cache(file_path, cache.clone());
+        let second = TrackingFileRead::new(Bytes::from(file_bytes.clone()))
+            .with_blob_index_cache(file_path, cache);
+
+        for reader in [first.clone(), second.clone()] {
+            IndexedBlobReader::open(
+                Box::new(reader),
+                file_bytes.len() as u64,
+                file_path.to_string(),
+                true,
+            )
+            .await
+            .unwrap();
+        }
+
+        assert_eq!(first.ranges().len(), 1);
+        assert_eq!(second.ranges().len(), 1);
+    }
+
+    #[tokio::test]
+    async fn test_blob_index_cache_coalesces_concurrent_loads() {
+        let file_path = "file:///blob-index-cache-concurrent/data.blob";
+        let file_bytes = load_blob_fixture("blob-basic.blob");
+        let cache = blob_index_cache("64 MiB");
+        let tracking = TrackingFileRead::new(Bytes::from(file_bytes.clone()))
+            .with_blob_index_cache(file_path, cache);
+
+        let open = |reader: TrackingFileRead| {
+            IndexedBlobReader::open(
+                Box::new(reader),
+                file_bytes.len() as u64,
+                file_path.to_string(),
+                true,
+            )
+        };
+        let (first, second) = tokio::join!(open(tracking.clone()), 
open(tracking.clone()));
+
+        assert_eq!(first.unwrap().num_rows(), second.unwrap().num_rows());
+        assert_eq!(tracking.ranges().len(), 1);
+    }
+
+    #[tokio::test]
+    async fn test_blob_index_cache_coalesces_failure_then_retries() {
+        let file_path = "file:///blob-index-cache-failure/data.blob";
+        let file_bytes = load_blob_fixture("blob-basic.blob");
+        let cache = blob_index_cache("64 MiB");
+        let failing = TrackingFileRead::new(Bytes::from(file_bytes.clone()))
+            .with_blob_index_cache(file_path, cache.clone())
+            .with_failure();
+
+        let open = |reader: TrackingFileRead| {
+            IndexedBlobReader::open(
+                Box::new(reader),
+                file_bytes.len() as u64,
+                file_path.to_string(),
+                true,
+            )
+        };
+        let failures = futures::future::join_all((0..8).map(|_| 
open(failing.clone()))).await;
+
+        assert!(failures.iter().all(Result::is_err));
+        for failure in failures {
+            let Error::UnexpectedError {
+                source: Some(source),
+                ..
+            } = failure.err().unwrap()
+            else {
+                panic!("cached failure lost its source");
+            };
+            let shared = 
source.downcast_ref::<SharedBlobIndexError>().unwrap();
+            assert!(std::error::Error::source(shared)
+                .unwrap()
+                .downcast_ref::<Error>()
+                .is_some());
+            assert!(matches!(
+                shared.0.as_ref(),
+                Error::UnexpectedError {
+                    source: Some(_),
+                    ..
+                }
+            ));
+        }
+        assert_eq!(failing.ranges().len(), 1);
+
+        let retry = TrackingFileRead::new(Bytes::from(file_bytes.clone()))
+            .with_blob_index_cache(file_path, cache);
+        assert_eq!(open(retry.clone()).await.unwrap().num_rows(), 4);
+        assert_eq!(retry.ranges().len(), 1);
+    }
+
+    #[test]
+    fn test_blob_index_cache_preserves_direct_error_cause() {
+        let original = Arc::new(Error::IoUnexpected {
+            message: "injected storage failure".to_string(),
+            source: Box::new(opendal::Error::new(
+                opendal::ErrorKind::Unsupported,
+                "injected native failure",
+            )),
+        });
+        let Error::UnexpectedError {
+            source: Some(source),
+            ..
+        } = clone_blob_index_error(&original)
+        else {
+            panic!("cached failure lost its cause");
+        };
+        let cause = std::error::Error::source(source.as_ref())
+            .unwrap()
+            .downcast_ref::<Error>()
+            .unwrap();
+        assert!(std::ptr::eq(cause, original.as_ref()));
+    }
+
+    #[tokio::test]
+    async fn test_blob_index_cache_preserves_io_error_category() {
+        let file_size = load_blob_fixture("blob-basic.blob").len() as u64;
+        for budget in ["64 MiB", "0"] {
+            let path = 
format!("file:///blob-index-io-error/{budget}/data.blob");
+            let reader = TrackingFileRead::new(Bytes::new())
+                .with_blob_index_cache(&path, blob_index_cache(budget))
+                .with_io_failure();
+
+            let direct = BlobFileIndex::load(&reader, 
file_size).await.unwrap_err();
+            assert!(matches!(direct, Error::IoUnexpected { .. }));
+            for _ in 0..2 {
+                let cached = IndexedBlobReader::open(
+                    Box::new(reader.clone()),
+                    file_size,
+                    path.clone(),
+                    true,
+                )
+                .await
+                .err()
+                .expect("BLOB index load should fail");
+                assert!(cached.is_io_unexpected(), "budget={budget}: 
{cached:?}");
+            }
+            assert_eq!(reader.ranges().len(), 3);
+        }
+    }
+
+    #[tokio::test]
+    async fn test_blob_index_cache_evicts_by_decoded_bytes() {
+        let first_key = "memory:/blob-index-cache-bytes/first.blob";
+        let second_key = "memory:/blob-index-cache-bytes/other.blob";
+        assert_eq!(first_key.len(), second_key.len());
+        let file_bytes = blob_test_utils::build_blob_file_bytes(&[None, None]);
+        let max_bytes = blob_index_cache_entry_weight(first_key, 2);
+        let cache = blob_index_cache(&max_bytes.to_string());
+        let first = TrackingFileRead::new(Bytes::from(file_bytes.clone()))
+            .with_blob_index_cache(first_key, cache.clone());
+        let second = TrackingFileRead::new(Bytes::from(file_bytes.clone()))
+            .with_blob_index_cache(second_key, cache.clone());
+        let first_again = 
TrackingFileRead::new(Bytes::from(file_bytes.clone()))
+            .with_blob_index_cache(first_key, cache);
+
+        for (reader, key) in [
+            (first.clone(), first_key),
+            (second.clone(), second_key),
+            (first_again.clone(), first_key),
+        ] {
+            IndexedBlobReader::open(
+                Box::new(reader),
+                file_bytes.len() as u64,
+                key.to_string(),
+                true,
+            )
+            .await
+            .unwrap();
+        }
+
+        assert_eq!(first.ranges().len(), 1);
+        assert_eq!(second.ranges().len(), 1);
+        assert_eq!(first_again.ranges().len(), 1);
+    }
+
+    #[tokio::test]
+    async fn test_oversized_blob_index_does_not_evict_cached_index() {
+        let small_key = "memory:/blob-index-cache-size/small.blob";
+        let large_key = "memory:/blob-index-cache-size/large.blob";
+        assert_eq!(small_key.len(), large_key.len());
+        let small_bytes = blob_test_utils::build_blob_file_bytes(&[None]);
+        let large_values = vec![None; 100];
+        let large_bytes = 
blob_test_utils::build_blob_file_bytes(&large_values);
+        let max_bytes = blob_index_cache_entry_weight(small_key, 1);
+        let cache = blob_index_cache(&max_bytes.to_string());
+        let small = TrackingFileRead::new(Bytes::from(small_bytes.clone()))
+            .with_blob_index_cache(small_key, cache.clone());
+        let large = TrackingFileRead::new(Bytes::from(large_bytes.clone()))
+            .with_blob_index_cache(large_key, cache.clone());
+        let small_again = 
TrackingFileRead::new(Bytes::from(small_bytes.clone()))
+            .with_blob_index_cache(small_key, cache);
+
+        IndexedBlobReader::open(
+            Box::new(small.clone()),
+            small_bytes.len() as u64,
+            small_key.to_string(),
+            true,
+        )
+        .await
+        .unwrap();
+        IndexedBlobReader::open(
+            Box::new(large.clone()),
+            large_bytes.len() as u64,
+            large_key.to_string(),
+            true,
+        )
+        .await
+        .unwrap();
+        IndexedBlobReader::open(
+            Box::new(small_again.clone()),
+            small_bytes.len() as u64,
+            small_key.to_string(),
+            true,
+        )
+        .await
+        .unwrap();
+
+        assert_eq!(small.ranges().len(), 1);
+        assert_eq!(large.ranges().len(), 1);
+        assert!(small_again.ranges().is_empty());
+    }
+
     #[tokio::test]
     async fn test_blob_index_cache_isolated_by_file_io() {
         let path = "memory:///blob-index-cache-namespace/data.blob";
@@ -2680,32 +3110,338 @@ mod tests {
             .await
             .unwrap();
 
-        let first_namespace = first_io
+        assert!(!Arc::ptr_eq(
+            &first_io.blob_index_cache(),
+            &second_io.blob_index_cache()
+        ));
+
+        assert_eq!(
+            read_scalar_blob_file(&first_io, path).await,
+            vec![None, Some(value.to_vec())]
+        );
+        assert_eq!(
+            read_scalar_blob_file(&second_io, path).await,
+            vec![Some(value.to_vec()), None]
+        );
+    }
+
+    #[tokio::test]
+    #[cfg(feature = "storage-oss")]
+    async fn test_blob_index_cache_isolated_by_storage_endpoint() {
+        let value = b"value";
+        let first_bytes = Bytes::from(blob_test_utils::build_blob_file_bytes(&[
+            None,
+            Some(value.as_slice()),
+        ]));
+        let second_bytes = 
Bytes::from(blob_test_utils::build_blob_file_bytes(&[
+            Some(value.as_slice()),
+            None,
+        ]));
+        assert_eq!(first_bytes.len(), second_bytes.len());
+        let first_endpoint = serve_blob_file(first_bytes.clone()).await;
+        let second_endpoint = serve_blob_file(second_bytes.clone()).await;
+        let cache_context = 
FileIOCacheContext::from_props(&HashMap::new()).unwrap();
+
+        let first_io = blob_oss_file_io(&first_endpoint, 
cache_context.clone());
+        let second_io = blob_oss_file_io(&second_endpoint, cache_context);
+        let path = "/data.blob";
+
+        let first = IndexedBlobReader::open(
+            
Box::new(first_io.new_input(path).unwrap().reader().await.unwrap()),
+            first_bytes.len() as u64,
+            path.to_string(),
+            false,
+        )
+        .await
+        .unwrap();
+        let second = IndexedBlobReader::open(
+            
Box::new(second_io.new_input(path).unwrap().reader().await.unwrap()),
+            second_bytes.len() as u64,
+            path.to_string(),
+            false,
+        )
+        .await
+        .unwrap();
+
+        assert_eq!(second.num_rows(), 2);
+        assert!(matches!(
+            first.read_positions(&[0]).await.unwrap().as_slice(),
+            [BlobReadValue::Null]
+        ));
+        assert!(matches!(
+            second.read_positions(&[0]).await.unwrap().as_slice(),
+            [BlobReadValue::Value(bytes)] if bytes.as_ref() == value
+        ));
+    }
+
+    #[tokio::test]
+    #[cfg(feature = "storage-azdls")]
+    async fn test_blob_index_cache_isolated_by_azure_account() {
+        let first_path = 
"abfss://[email protected]/data.blob";
+        let second_path = 
"abfss://[email protected]/data.blob";
+        let file_io = FileIO::from_path(first_path)
+            .unwrap()
+            .with_prop("azure.account-key", "account-key")
+            .build()
+            .unwrap();
+        let first_key = file_io
+            .new_input(first_path)
+            .unwrap()
+            .reader()
+            .await
+            .unwrap()
+            .cache_key()
+            .unwrap()
+            .to_string();
+        let second_key = file_io
+            .new_input(second_path)
+            .unwrap()
+            .reader()
+            .await
+            .unwrap()
+            .cache_key()
+            .unwrap()
+            .to_string();
+        assert_ne!(first_key, second_key);
+
+        let value = b"value";
+        let first_bytes = blob_test_utils::build_blob_file_bytes(&[None, 
Some(value)]);
+        let second_bytes = 
blob_test_utils::build_blob_file_bytes(&[Some(value), None]);
+        let cache = blob_index_cache("64 MiB");
+        let first = TrackingFileRead::new(Bytes::from(first_bytes.clone()))
+            .with_blob_index_cache(first_key, cache.clone());
+        let second = TrackingFileRead::new(Bytes::from(second_bytes.clone()))
+            .with_blob_index_cache(second_key, cache);
+        let first = IndexedBlobReader::open(
+            Box::new(first),
+            first_bytes.len() as u64,
+            first_path.to_string(),
+            false,
+        )
+        .await
+        .unwrap();
+        let second = IndexedBlobReader::open(
+            Box::new(second),
+            second_bytes.len() as u64,
+            second_path.to_string(),
+            false,
+        )
+        .await
+        .unwrap();
+        assert!(matches!(
+            first.read_positions(&[0]).await.unwrap().as_slice(),
+            [BlobReadValue::Null]
+        ));
+        assert!(matches!(
+            second.read_positions(&[0]).await.unwrap().as_slice(),
+            [BlobReadValue::Value(bytes)] if bytes.as_ref() == value
+        ));
+    }
+
+    #[tokio::test]
+    #[cfg(feature = "storage-hdfs")]
+    async fn test_blob_index_cache_isolated_by_hdfs_name_node() {
+        let path = "hdfs://logical-cluster/table/data.blob";
+        let first_io = FileIO::from_path(path)
+            .unwrap()
+            .with_prop("hdfs.name-node", "hdfs://cluster-a:8020")
+            .build()
+            .unwrap();
+        let second_io = FileIO::from_path(path)
+            .unwrap()
+            .with_prop("hdfs.name-node", "hdfs://cluster-b:8020")
+            .with_cache_context(first_io.cache_context())
+            .build()
+            .unwrap();
+        let first_key = first_io
             .new_input(path)
             .unwrap()
             .reader()
             .await
             .unwrap()
-            .cache_namespace();
-        let second_namespace = second_io
+            .cache_key()
+            .unwrap()
+            .to_string();
+        let second_key = second_io
             .new_input(path)
             .unwrap()
             .reader()
             .await
             .unwrap()
-            .cache_namespace();
-        assert_ne!(first_namespace, second_namespace);
+            .cache_key()
+            .unwrap()
+            .to_string();
+        assert_ne!(first_key, second_key);
 
-        assert_eq!(
-            read_scalar_blob_file(&first_io, path).await,
-            vec![None, Some(value.to_vec())]
+        let value = b"value";
+        let first_bytes = blob_test_utils::build_blob_file_bytes(&[None, 
Some(value)]);
+        let second_bytes = 
blob_test_utils::build_blob_file_bytes(&[Some(value), None]);
+        assert_eq!(first_bytes.len(), second_bytes.len());
+        let cache = first_io.blob_index_cache();
+        let first = TrackingFileRead::new(Bytes::from(first_bytes.clone()))
+            .with_blob_index_cache(first_key, cache.clone());
+        let second = TrackingFileRead::new(Bytes::from(second_bytes.clone()))
+            .with_blob_index_cache(second_key, cache);
+        let first = IndexedBlobReader::open(
+            Box::new(first),
+            first_bytes.len() as u64,
+            path.to_string(),
+            false,
+        )
+        .await
+        .unwrap();
+        let second = IndexedBlobReader::open(
+            Box::new(second),
+            second_bytes.len() as u64,
+            path.to_string(),
+            false,
+        )
+        .await
+        .unwrap();
+        assert!(matches!(
+            first.read_positions(&[0]).await.unwrap().as_slice(),
+            [BlobReadValue::Null]
+        ));
+        assert!(
+            matches!(second.read_positions(&[0]).await.unwrap().as_slice(), 
[BlobReadValue::Value(bytes)] if bytes.as_ref() == value)
         );
-        assert_eq!(
-            read_scalar_blob_file(&second_io, path).await,
-            vec![Some(value.to_vec()), None]
+    }
+
+    #[derive(Debug)]
+    #[cfg(feature = "storage-oss")]
+    struct FixedBlobProvider(Operator);
+
+    #[async_trait]
+    #[cfg(feature = "storage-oss")]
+    impl FileIOProvider for FixedBlobProvider {
+        async fn create(&self, _path: &str) -> crate::Result<(Operator, 
String)> {
+            Ok((self.0.clone(), "data.blob".to_string()))
+        }
+    }
+
+    #[derive(Debug)]
+    #[cfg(feature = "storage-oss")]
+    struct RoutedBlobProvider {
+        first: Operator,
+        second: Operator,
+    }
+
+    #[async_trait]
+    #[cfg(feature = "storage-oss")]
+    impl FileIOProvider for RoutedBlobProvider {
+        async fn create(&self, path: &str) -> crate::Result<(Operator, 
String)> {
+            let op = if path.starts_with("oss://first/") {
+                &self.first
+            } else {
+                &self.second
+            };
+            Ok((op.clone(), "data.blob".to_string()))
+        }
+    }
+
+    #[tokio::test]
+    #[cfg(feature = "storage-oss")]
+    async fn test_blob_index_cache_bypassed_for_opaque_provider_routes() {
+        let value = b"value";
+        let first_bytes = 
Bytes::from(blob_test_utils::build_blob_file_bytes(&[None, Some(value)]));
+        let second_bytes =
+            Bytes::from(blob_test_utils::build_blob_file_bytes(&[Some(value), 
None]));
+        assert_eq!(first_bytes.len(), second_bytes.len());
+        let first_endpoint = serve_blob_file(first_bytes.clone()).await;
+        let second_endpoint = serve_blob_file(second_bytes.clone()).await;
+        let io = FileIOBuilder::new("fs")
+            .with_provider(Arc::new(RoutedBlobProvider {
+                first: blob_oss_operator(&first_endpoint),
+                second: blob_oss_operator(&second_endpoint),
+            }))
+            .build()
+            .unwrap();
+        let first_path = "oss://first/data.blob";
+        let second_path = "oss://second/data.blob";
+        let first_reader = 
io.new_input(first_path).unwrap().reader().await.unwrap();
+        let second_reader = 
io.new_input(second_path).unwrap().reader().await.unwrap();
+        assert_eq!(first_reader.cache_key(), None);
+        assert_eq!(second_reader.cache_key(), None);
+        let first = IndexedBlobReader::open(
+            Box::new(first_reader),
+            first_bytes.len() as u64,
+            first_path.to_string(),
+            false,
+        )
+        .await
+        .unwrap();
+        let second = IndexedBlobReader::open(
+            Box::new(second_reader),
+            second_bytes.len() as u64,
+            second_path.to_string(),
+            false,
+        )
+        .await
+        .unwrap();
+        assert!(matches!(
+            first.read_positions(&[0]).await.unwrap().as_slice(),
+            [BlobReadValue::Null]
+        ));
+        assert!(
+            matches!(second.read_positions(&[0]).await.unwrap().as_slice(), 
[BlobReadValue::Value(bytes)] if bytes.as_ref() == value)
         );
     }
 
+    #[tokio::test]
+    #[cfg(feature = "storage-oss")]
+    async fn test_blob_index_cache_isolated_after_provider_replacement() {
+        let value = b"value";
+        let first_bytes = Bytes::from(blob_test_utils::build_blob_file_bytes(&[
+            None,
+            Some(value.as_slice()),
+        ]));
+        let second_bytes = 
Bytes::from(blob_test_utils::build_blob_file_bytes(&[
+            Some(value.as_slice()),
+            None,
+        ]));
+        let first_endpoint = serve_blob_file(first_bytes.clone()).await;
+        let second_endpoint = serve_blob_file(second_bytes.clone()).await;
+        let cache_context = 
FileIOCacheContext::from_props(&HashMap::new()).unwrap();
+        let original = FileIOBuilder::new("fs")
+            .with_cache_context(cache_context)
+            .build()
+            .unwrap()
+            .with_provider(Arc::new(FixedBlobProvider(blob_oss_operator(
+                &first_endpoint,
+            ))));
+        let replacement =
+            original
+                .clone()
+                .with_provider(Arc::new(FixedBlobProvider(blob_oss_operator(
+                    &second_endpoint,
+                ))));
+        let path = "oss://bucket/data.blob";
+        let first = IndexedBlobReader::open(
+            
Box::new(original.new_input(path).unwrap().reader().await.unwrap()),
+            first_bytes.len() as u64,
+            path.to_string(),
+            false,
+        )
+        .await
+        .unwrap();
+        let second = IndexedBlobReader::open(
+            
Box::new(replacement.new_input(path).unwrap().reader().await.unwrap()),
+            second_bytes.len() as u64,
+            path.to_string(),
+            false,
+        )
+        .await
+        .unwrap();
+        assert!(matches!(
+            first.read_positions(&[0]).await.unwrap().as_slice(),
+            [BlobReadValue::Null]
+        ));
+        assert!(matches!(
+            second.read_positions(&[0]).await.unwrap().as_slice(),
+            [BlobReadValue::Value(bytes)] if bytes.as_ref() == value
+        ));
+    }
+
     #[tokio::test]
     async fn test_blob_array_reader_reads_java_fixture() {
         let read_fields = vec![DataField::new(
@@ -4072,10 +4808,92 @@ mod tests {
         std::fs::read(&path).unwrap_or_else(|e| panic!("Failed to read {path}: 
{e}"))
     }
 
+    fn blob_index_cache(max_size: &str) -> Arc<BlobIndexCacheContext> {
+        BlobIndexCacheContext::from_props(&std::collections::HashMap::from([(
+            CatalogOptions::BLOB_INDEX_CACHE_MAX_SIZE.to_string(),
+            max_size.to_string(),
+        )]))
+        .unwrap()
+    }
+
+    fn blob_index_cache_entry_weight(key: &str, rows: usize) -> usize {
+        let index = BlobFileIndex {
+            entries: vec![BlobEntry::Null; rows],
+        };
+        FileMetadataCache::<String, BlobFileIndex>::entry_weight(
+            key.len(),
+            index.estimated_cache_bytes(),
+        )
+    }
+
+    #[cfg(feature = "storage-oss")]
+    async fn serve_blob_file(bytes: Bytes) -> String {
+        async fn get_blob(State(bytes): State<Bytes>, headers: HeaderMap) -> 
Response<Body> {
+            let range = headers
+                .get(RANGE)
+                .and_then(|value| value.to_str().ok())
+                .and_then(|value| value.strip_prefix("bytes="))
+                .and_then(|value| value.split_once('-'))
+                .and_then(|(start, end)| {
+                    Some((start.parse::<usize>().ok()?, 
end.parse::<usize>().ok()?))
+                });
+            let Some((start, end)) = range else {
+                return Response::builder()
+                    .status(StatusCode::OK)
+                    .header(CONTENT_LENGTH, bytes.len())
+                    .body(Body::from(bytes))
+                    .unwrap();
+            };
+            let body = bytes.slice(start..=end);
+            Response::builder()
+                .status(StatusCode::PARTIAL_CONTENT)
+                .header(CONTENT_LENGTH, body.len())
+                .header(
+                    CONTENT_RANGE,
+                    format!("bytes {start}-{end}/{}", bytes.len()),
+                )
+                .body(Body::from(body))
+                .unwrap()
+        }
+
+        let app = Router::new().fallback(get(get_blob)).with_state(bytes);
+        let listener = 
tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
+        let address = listener.local_addr().unwrap();
+        tokio::spawn(async move { axum::serve(listener, app).await.unwrap() });
+        format!("http://{address}";)
+    }
+
+    #[cfg(feature = "storage-oss")]
+    fn blob_oss_file_io(endpoint: &str, cache_context: FileIOCacheContext) -> 
FileIO {
+        FileIOBuilder::new("fs")
+            .with_prop("fs.oss.endpoint", endpoint)
+            .with_fs_operator(blob_oss_operator(endpoint))
+            .with_cache_context(cache_context)
+            .build()
+            .unwrap()
+    }
+
+    #[cfg(feature = "storage-oss")]
+    fn blob_oss_operator(endpoint: &str) -> Operator {
+        let mut config = OssConfig::default();
+        config.endpoint = Some(endpoint.to_string());
+        config.addressing_style = Some("path".to_string());
+        config.skip_signature = true;
+        Operator::new(config.into_builder().bucket("bucket"))
+            .unwrap()
+            .with_context(
+                OperationContext::new()
+                    
.with_http_transport(HttpTransporter::new(ReqwestTransport::default())),
+            )
+    }
+
     #[derive(Clone)]
     struct TrackingFileRead {
         bytes: Bytes,
-        cache_namespace: Option<usize>,
+        cache_key: Option<String>,
+        blob_index_cache: Option<Arc<BlobIndexCacheContext>>,
+        fail: bool,
+        io_fail: bool,
         in_flight: Arc<AtomicUsize>,
         max_in_flight: Arc<AtomicUsize>,
         ranges: Arc<Mutex<Vec<Range<u64>>>>,
@@ -4085,15 +4903,33 @@ mod tests {
         fn new(bytes: Bytes) -> Self {
             Self {
                 bytes,
-                cache_namespace: None,
+                cache_key: None,
+                blob_index_cache: None,
+                fail: false,
+                io_fail: false,
                 in_flight: Arc::new(AtomicUsize::new(0)),
                 max_in_flight: Arc::new(AtomicUsize::new(0)),
                 ranges: Arc::new(Mutex::new(Vec::new())),
             }
         }
 
-        fn with_cache_namespace(mut self, cache_namespace: usize) -> Self {
-            self.cache_namespace = Some(cache_namespace);
+        fn with_blob_index_cache(
+            mut self,
+            cache_key: impl Into<String>,
+            cache: Arc<BlobIndexCacheContext>,
+        ) -> Self {
+            self.cache_key = Some(cache_key.into());
+            self.blob_index_cache = Some(cache);
+            self
+        }
+
+        fn with_failure(mut self) -> Self {
+            self.fail = true;
+            self
+        }
+
+        fn with_io_failure(mut self) -> Self {
+            self.io_fail = true;
             self
         }
 
@@ -4114,11 +4950,32 @@ mod tests {
             self.max_in_flight.fetch_max(in_flight, Ordering::SeqCst);
             tokio::time::sleep(Duration::from_millis(10)).await;
             self.in_flight.fetch_sub(1, Ordering::SeqCst);
+            if self.io_fail {
+                return Err(Error::IoUnexpected {
+                    message: "injected BLOB index I/O failure".to_string(),
+                    source: Box::new(opendal::Error::new(
+                        opendal::ErrorKind::Unexpected,
+                        "injected storage failure",
+                    )),
+                });
+            }
+            if self.fail {
+                return Err(Error::UnexpectedError {
+                    message: "injected BLOB index read failure".to_string(),
+                    source: Some(Box::new(std::io::Error::other("injected 
source"))),
+                });
+            }
             Ok(self.bytes.slice(range.start as usize..range.end as usize))
         }
 
-        fn cache_namespace(&self) -> Option<usize> {
-            self.cache_namespace
+        fn cache_key(&self) -> Option<&str> {
+            self.cache_key.as_deref()
+        }
+
+        fn blob_index_cache(&self) -> Option<&(dyn std::any::Any + Send + 
Sync)> {
+            self.blob_index_cache
+                .as_deref()
+                .map(|cache| cache as &(dyn std::any::Any + Send + Sync))
         }
     }
 
diff --git a/crates/paimon/src/arrow/format/metadata_cache.rs 
b/crates/paimon/src/arrow/format/metadata_cache.rs
index 5bb140ad..94a0c45c 100644
--- a/crates/paimon/src/arrow/format/metadata_cache.rs
+++ b/crates/paimon/src/arrow/format/metadata_cache.rs
@@ -100,6 +100,24 @@ where
         F: FnOnce() -> Fut,
         Fut: Future<Output = Result<Arc<V>, E>>,
         W: FnOnce(&V) -> usize,
+    {
+        self.get_or_try_insert_with_admission(key, key_heap_bytes, load, 
|value| {
+            Some(value_weight(value))
+        })
+        .await
+    }
+
+    pub(super) async fn get_or_try_insert_with_admission<E, F, Fut, W>(
+        &self,
+        key: Option<K>,
+        key_heap_bytes: usize,
+        load: F,
+        admitted_weight: W,
+    ) -> Result<Arc<V>, E>
+    where
+        F: FnOnce() -> Fut,
+        Fut: Future<Output = Result<Arc<V>, E>>,
+        W: FnOnce(&V) -> Option<usize>,
     {
         let Some(key) = key else {
             return load().await;
@@ -133,9 +151,19 @@ where
         if entry.weight.load(Ordering::Relaxed) != entry.base_weight {
             return Ok(value);
         }
-        let loaded_weight = entry
-            .base_weight
-            .saturating_add(value_weight(value.as_ref()).max(1));
+        let Some(value_weight) = admitted_weight(value.as_ref()) else {
+            let mut state = self.state.lock().unwrap();
+            if state
+                .entries
+                .peek(&key)
+                .is_some_and(|cached| Arc::ptr_eq(cached, &entry))
+            {
+                state.entries.pop(&key);
+                state.weight = state.weight.saturating_sub(entry.base_weight);
+            }
+            return Ok(value);
+        };
+        let loaded_weight = 
entry.base_weight.saturating_add(value_weight.max(1));
         let mut state = self.state.lock().unwrap();
         if state
             .entries
diff --git a/crates/paimon/src/catalog/filesystem.rs 
b/crates/paimon/src/catalog/filesystem.rs
index 4d48de2a..a95a8244 100644
--- a/crates/paimon/src/catalog/filesystem.rs
+++ b/crates/paimon/src/catalog/filesystem.rs
@@ -136,7 +136,9 @@ impl FileSystemCatalog {
     }
 
     fn build_file_io(&self, path: &str) -> Result<FileIO> {
-        let mut builder = 
FileIO::from_path(path)?.with_props(self.options.to_map().iter());
+        let mut builder = FileIO::from_path(path)?
+            .with_props(self.options.to_map().iter())
+            .with_cache_context(self.file_io.cache_context());
         if let Some(local_cache) = &self.local_cache {
             builder = builder.with_local_cache(local_cache.clone());
         }
@@ -820,6 +822,7 @@ fn fill_table_name(err: Error, identifier: &Identifier) -> 
Error {
 #[cfg(not(windows))]
 mod tests {
     use super::*;
+    use std::sync::Arc;
     use tempfile::TempDir;
 
     /// Returns a temp dir guard (keep alive for test duration) and a catalog 
using it as warehouse.
@@ -853,6 +856,21 @@ mod tests {
         assert!(catalog.file_io().has_local_cache());
     }
 
+    #[test]
+    fn test_filesystem_catalog_derived_file_io_shares_metadata_caches() {
+        let catalog = create_memory_catalog();
+        let derived = catalog.build_file_io("memory:/objects").unwrap();
+
+        assert!(Arc::ptr_eq(
+            &catalog.file_io().file_format_metadata_cache(),
+            &derived.file_format_metadata_cache()
+        ));
+        assert!(Arc::ptr_eq(
+            &catalog.file_io().blob_index_cache(),
+            &derived.blob_index_cache()
+        ));
+    }
+
     fn testing_schema() -> Schema {
         Schema::builder()
             .column(
diff --git a/crates/paimon/src/catalog/rest/rest_catalog.rs 
b/crates/paimon/src/catalog/rest/rest_catalog.rs
index 67807f0e..1097f799 100644
--- a/crates/paimon/src/catalog/rest/rest_catalog.rs
+++ b/crates/paimon/src/catalog/rest/rest_catalog.rs
@@ -37,7 +37,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::FileFormatMetadataCacheContext;
+use crate::io::FileIOCacheContext;
 use crate::spec::{Partition, PartitionStatistics, Schema, SchemaChange};
 use crate::table::{RESTEnv, Table};
 use crate::Result;
@@ -61,7 +61,7 @@ pub struct RESTCatalog {
     data_token_enabled: bool,
     /// Catalog-scoped cache shared by all table FileIO instances.
     local_cache: Option<Arc<LocalCache>>,
-    file_format_metadata_cache: Arc<FileFormatMetadataCacheContext>,
+    file_io_cache: FileIOCacheContext,
 }
 
 impl RESTCatalog {
@@ -90,8 +90,7 @@ impl RESTCatalog {
 
         let api_options = api.options().clone();
         let local_cache = create_local_cache_with_namespace(&options, 
&api_options)?;
-        let file_format_metadata_cache =
-            FileFormatMetadataCacheContext::from_props(api_options.to_map())?;
+        let file_io_cache = 
FileIOCacheContext::from_props(api_options.to_map())?;
 
         Ok(Self {
             api,
@@ -99,7 +98,7 @@ impl RESTCatalog {
             warehouse,
             data_token_enabled,
             local_cache,
-            file_format_metadata_cache,
+            file_io_cache,
         })
     }
 
@@ -285,7 +284,7 @@ impl Catalog for RESTCatalog {
             self.options.clone(),
             self.data_token_enabled,
             self.local_cache.clone(),
-            self.file_format_metadata_cache.clone(),
+            self.file_io_cache.clone(),
         )
         .await
     }
@@ -303,7 +302,7 @@ impl Catalog for RESTCatalog {
                     self.options.clone(),
                     self.data_token_enabled,
                     self.local_cache.clone(),
-                    self.file_format_metadata_cache.clone(),
+                    self.file_io_cache.clone(),
                 )
                 .await
                 .map(crate::catalog::LoadedTable::Object);
@@ -324,7 +323,7 @@ impl Catalog for RESTCatalog {
             self.options.clone(),
             self.data_token_enabled,
             self.local_cache.clone(),
-            self.file_format_metadata_cache.clone(),
+            self.file_io_cache.clone(),
         )
         .await
         .map(|table| crate::catalog::LoadedTable::Paimon(Box::new(table)))
diff --git a/crates/paimon/src/catalog/rest/rest_token_file_io.rs 
b/crates/paimon/src/catalog/rest/rest_token_file_io.rs
index 7f106011..446a56ea 100644
--- a/crates/paimon/src/catalog/rest/rest_token_file_io.rs
+++ b/crates/paimon/src/catalog/rest/rest_token_file_io.rs
@@ -31,7 +31,7 @@ use crate::api::rest_util::RESTUtil;
 use crate::catalog::Identifier;
 use crate::common::{CatalogOptions, Options};
 use crate::io::cache::LocalCache;
-use crate::io::{FileFormatMetadataCacheContext, FileIO, FileIOProvider};
+use crate::io::{FileIO, FileIOCacheContext, FileIOProvider};
 use crate::Result;
 
 use super::rest_token::RESTToken;
@@ -55,7 +55,7 @@ pub struct RESTTokenFileIO {
     state: RwLock<Option<TokenState>>,
     refresh_lock: Mutex<()>,
     local_cache: Option<Arc<LocalCache>>,
-    file_format_metadata_cache: Arc<FileFormatMetadataCacheContext>,
+    file_io_cache: FileIOCacheContext,
 }
 
 impl std::fmt::Debug for RESTTokenFileIO {
@@ -74,7 +74,7 @@ impl RESTTokenFileIO {
         catalog_options: Options,
         api: Arc<RESTApi>,
         local_cache: Option<Arc<LocalCache>>,
-        file_format_metadata_cache: Arc<FileFormatMetadataCacheContext>,
+        file_io_cache: FileIOCacheContext,
     ) -> Self {
         Self {
             identifier,
@@ -84,7 +84,7 @@ impl RESTTokenFileIO {
             state: RwLock::new(None),
             refresh_lock: Mutex::new(()),
             local_cache,
-            file_format_metadata_cache,
+            file_io_cache,
         }
     }
 
@@ -124,7 +124,7 @@ impl RESTTokenFileIO {
     fn build_static_file_io(&self, token: &RESTToken) -> Result<FileIO> {
         let merged_props = 
RESTUtil::merge(Some(self.catalog_options.to_map()), Some(&token.token));
         let mut builder = 
FileIO::from_path(&self.path)?.with_props(merged_props);
-        builder = 
builder.with_file_format_metadata_cache(self.file_format_metadata_cache.clone());
+        builder = builder.with_cache_context(self.file_io_cache.clone());
         if let Some(local_cache) = &self.local_cache {
             builder = builder.with_local_cache(local_cache.clone());
         }
@@ -175,6 +175,16 @@ impl FileIOProvider for RESTTokenFileIO {
     async fn create(&self, path: &str) -> Result<(opendal::Operator, String)> {
         self.current_file_io().await?.create_static(path)
     }
+
+    async fn create_with_cache_namespace(
+        &self,
+        path: &str,
+    ) -> Result<(opendal::Operator, String, Option<String>)> {
+        let file_io = self.current_file_io().await?;
+        let cache_namespace = file_io.cache_namespace_for_path(path)?;
+        let (op, relative_path) = file_io.create_static(path)?;
+        Ok((op, relative_path, Some(cache_namespace)))
+    }
 }
 
 #[cfg(test)]
@@ -233,8 +243,8 @@ mod tests {
         (options, api, requests, server)
     }
 
-    fn metadata_cache(options: &Options) -> 
Arc<FileFormatMetadataCacheContext> {
-        FileFormatMetadataCacheContext::from_props(options.to_map()).unwrap()
+    fn file_io_cache(options: &Options) -> FileIOCacheContext {
+        FileIOCacheContext::from_props(options.to_map()).unwrap()
     }
 
     #[tokio::test]
@@ -243,14 +253,14 @@ mod tests {
         let (mut options, api, _, server) = token_api().await;
         options.set(CatalogOptions::LOCAL_CACHE_ENABLED, "true");
         let local_cache = create_local_cache(&options).unwrap();
-        let metadata_cache = metadata_cache(&options);
+        let file_io_cache = file_io_cache(&options);
         let token_file_io = Arc::new(RESTTokenFileIO::new(
             Identifier::new("database", "table"),
             table_directory.path().to_string_lossy().into_owned(),
             options,
             api,
             local_cache,
-            metadata_cache,
+            file_io_cache,
         ));
 
         let file_io = token_file_io.build_file_io().await.unwrap();
@@ -264,14 +274,14 @@ mod tests {
         let table_directory = tempfile::tempdir().unwrap();
         let file_path = table_directory.path().join("data");
         let (options, api, requests, server) = token_api().await;
-        let metadata_cache = metadata_cache(&options);
+        let file_io_cache = file_io_cache(&options);
         let token_file_io = Arc::new(RESTTokenFileIO::new(
             Identifier::new("database", "table"),
             table_directory.path().to_string_lossy().into_owned(),
             options,
             api,
             None,
-            metadata_cache,
+            file_io_cache,
         ));
 
         let file_io = token_file_io.build_file_io().await.unwrap();
@@ -313,14 +323,14 @@ mod tests {
         let file_path = table_directory.path().join("blob");
         std::fs::write(&file_path, b"abcdefghij").unwrap();
         let (options, api, requests, server) = token_api().await;
-        let metadata_cache = metadata_cache(&options);
+        let file_io_cache = file_io_cache(&options);
         let token_file_io = Arc::new(RESTTokenFileIO::new(
             Identifier::new("database", "table"),
             table_directory.path().to_string_lossy().into_owned(),
             options,
             api,
             None,
-            metadata_cache,
+            file_io_cache,
         ));
 
         let file_io = token_file_io.build_file_io().await.unwrap();
@@ -343,14 +353,14 @@ mod tests {
         let file_path = table_directory.path().join("blob");
         std::fs::write(&file_path, b"abcdefghij").unwrap();
         let (options, api, requests, server) = token_api().await;
-        let metadata_cache = metadata_cache(&options);
+        let file_io_cache = file_io_cache(&options);
         let token_file_io = Arc::new(RESTTokenFileIO::new(
             Identifier::new("database", "table"),
             table_directory.path().to_string_lossy().into_owned(),
             options,
             api,
             None,
-            metadata_cache,
+            file_io_cache,
         ));
 
         let file_io = token_file_io.build_file_io().await.unwrap();
diff --git a/crates/paimon/src/common/options.rs 
b/crates/paimon/src/common/options.rs
index 0110b80c..3eed389e 100644
--- a/crates/paimon/src/common/options.rs
+++ b/crates/paimon/src/common/options.rs
@@ -94,6 +94,9 @@ impl CatalogOptions {
     /// Maximum process-local cache size for parsed file-format metadata.
     pub const FILE_FORMAT_METADATA_CACHE_MAX_SIZE: &'static str =
         "file-format.metadata-cache.max-size";
+
+    /// Maximum catalog-local cache size for decoded BLOB indexes.
+    pub const BLOB_INDEX_CACHE_MAX_SIZE: &'static str = 
"cache.blob-index.max-size";
 }
 
 /// Configuration options container.
@@ -288,6 +291,10 @@ mod tests {
             CatalogOptions::FILE_FORMAT_METADATA_CACHE_MAX_SIZE,
             "file-format.metadata-cache.max-size"
         );
+        assert_eq!(
+            CatalogOptions::BLOB_INDEX_CACHE_MAX_SIZE,
+            "cache.blob-index.max-size"
+        );
     }
 
     #[test]
diff --git a/crates/paimon/src/error.rs b/crates/paimon/src/error.rs
index 47cd9e6e..aa53a6dc 100644
--- a/crates/paimon/src/error.rs
+++ b/crates/paimon/src/error.rs
@@ -179,6 +179,36 @@ impl Error {
             _ => false,
         }
     }
+
+    /// Whether a shared BLOB index failure wraps an I/O error.
+    #[doc(hidden)]
+    pub fn is_io_unexpected(&self) -> bool {
+        match self {
+            Error::IoUnexpected { .. } => true,
+            Error::UnexpectedError {
+                source: Some(source),
+                ..
+            } => error_chain_contains_io_unexpected(source.as_ref()),
+            _ => false,
+        }
+    }
+}
+
+fn error_chain_contains_io_unexpected(error: &(dyn std::error::Error + 
'static)) -> bool {
+    if let Some(error) = error.downcast_ref::<Error>() {
+        return error.is_io_unexpected();
+    }
+    if let Some(error) = error.downcast_ref::<std::io::Error>() {
+        if error
+            .get_ref()
+            .is_some_and(|source| error_chain_contains_io_unexpected(source))
+        {
+            return true;
+        }
+    }
+    error
+        .source()
+        .is_some_and(error_chain_contains_io_unexpected)
 }
 
 fn is_jindo_fork_error(error: &opendal::Error) -> bool {
diff --git a/crates/paimon/src/io/file_io.rs b/crates/paimon/src/io/file_io.rs
index 1f370b5a..e81af113 100644
--- a/crates/paimon/src/io/file_io.rs
+++ b/crates/paimon/src/io/file_io.rs
@@ -22,7 +22,6 @@ use std::collections::HashMap;
 use std::future::Future;
 use std::ops::Range;
 use std::pin::Pin;
-use std::sync::atomic::{AtomicUsize, Ordering};
 use std::sync::{Arc, OnceLock};
 use std::task::{Context, Poll};
 use std::time::SystemTime;
@@ -33,6 +32,7 @@ use futures::stream::BoxStream;
 use futures::{StreamExt, TryStreamExt};
 use opendal::raw::{normalize_path, normalize_root};
 use opendal::Operator;
+use sha2::{Digest, Sha256};
 use tokio_util::compat::FuturesAsyncWriteCompatExt;
 use url::Url;
 
@@ -68,8 +68,8 @@ pub trait FileBlockCache: std::fmt::Debug + Send + Sync + 
'static {
 /// Already-open readers and writers retain their operator, so its backend must
 /// refresh credentials internally if those handles need to outlive 
credentials.
 ///
-/// Custom services must report distinct `(scheme, name, root)` storage 
identities
-/// for different namespaces, since these identities are used by the file 
cache.
+/// Caching is disabled unless `create_with_cache_namespace` supplies a stable
+/// identity for the concrete storage target.
 #[async_trait::async_trait]
 pub trait FileIOProvider: std::fmt::Debug + Send + Sync + 'static {
     /// Return an operator and its relative path, preserving literal object 
keys.
@@ -80,6 +80,16 @@ pub trait FileIOProvider: std::fmt::Debug + Send + Sync + 
'static {
     /// Object paths that OpenDAL would trim or collapse are rejected by 
FileIO.
     /// Rename requires both paths to resolve to the same shared service 
instance.
     async fn create(&self, path: &str) -> crate::Result<(Operator, String)>;
+
+    /// Return a stable namespace for the resolved storage target. `None`
+    /// disables caching; distinct endpoints must use distinct namespaces.
+    async fn create_with_cache_namespace(
+        &self,
+        path: &str,
+    ) -> crate::Result<(Operator, String, Option<String>)> {
+        let (op, relative_path) = self.create(path).await?;
+        Ok((op, relative_path, None))
+    }
 }
 
 #[derive(Clone, Debug)]
@@ -91,18 +101,14 @@ enum FileIOBackend {
 #[derive(Clone)]
 pub struct FileIO {
     backend: FileIOBackend,
+    cache_namespace: Arc<str>,
     cache: Option<Arc<LocalCache>>,
-    cache_namespace: usize,
     file_format_metadata_cache: Arc<FileFormatMetadataCacheContext>,
-}
-
-static NEXT_FILE_IO_CACHE_NAMESPACE: AtomicUsize = AtomicUsize::new(1);
-
-fn next_file_io_cache_namespace() -> usize {
-    NEXT_FILE_IO_CACHE_NAMESPACE.fetch_add(1, Ordering::Relaxed)
+    blob_index_cache: Arc<BlobIndexCacheContext>,
 }
 
 pub(crate) const DEFAULT_FILE_FORMAT_METADATA_CACHE_MAX_BYTES: usize = 50 * 
1024 * 1024;
+pub(crate) const DEFAULT_BLOB_INDEX_CACHE_MAX_BYTES: usize = 64 * 1024 * 1024;
 
 pub(crate) struct FileFormatMetadataCacheContext {
     max_bytes: usize,
@@ -159,6 +165,82 @@ impl FileFormatMetadataCacheContext {
     }
 }
 
+pub(crate) struct BlobIndexCacheContext {
+    max_bytes: usize,
+    process_id: u32,
+    cache: OnceLock<Arc<dyn Any + Send + Sync>>,
+}
+
+impl std::fmt::Debug for BlobIndexCacheContext {
+    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
+        f.debug_struct("BlobIndexCacheContext")
+            .field("max_bytes", &self.max_bytes)
+            .finish_non_exhaustive()
+    }
+}
+
+impl BlobIndexCacheContext {
+    pub(crate) fn from_props(props: &HashMap<String, String>) -> 
crate::Result<Arc<Self>> {
+        let max_bytes = props
+            .get(CatalogOptions::BLOB_INDEX_CACHE_MAX_SIZE)
+            .map(|value| {
+                parse_memory_size(value)
+                    .ok()
+                    .and_then(|bytes| usize::try_from(bytes).ok())
+                    .ok_or_else(|| Error::ConfigInvalid {
+                        message: format!(
+                            "Invalid value for {}: {value}",
+                            CatalogOptions::BLOB_INDEX_CACHE_MAX_SIZE
+                        ),
+                    })
+            })
+            .transpose()?
+            .unwrap_or(DEFAULT_BLOB_INDEX_CACHE_MAX_BYTES);
+        Ok(Arc::new(Self {
+            max_bytes,
+            process_id: std::process::id(),
+            cache: OnceLock::new(),
+        }))
+    }
+
+    pub(crate) fn belongs_to_current_process(&self) -> bool {
+        self.process_id == std::process::id()
+    }
+
+    pub(crate) fn max_bytes(&self) -> usize {
+        self.max_bytes
+    }
+
+    pub(crate) fn get_or_init<T>(&self, init: impl FnOnce(usize) -> T) -> 
Arc<T>
+    where
+        T: Send + Sync + 'static,
+    {
+        let cache = Arc::clone(
+            self.cache
+                .get_or_init(|| Arc::new(init(self.max_bytes)) as Arc<dyn Any 
+ Send + Sync>),
+        );
+        match cache.downcast::<T>() {
+            Ok(cache) => cache,
+            Err(_) => panic!("BLOB index cache type mismatch"),
+        }
+    }
+}
+
+#[derive(Clone, Debug)]
+pub(crate) struct FileIOCacheContext {
+    file_format_metadata_cache: Arc<FileFormatMetadataCacheContext>,
+    blob_index_cache: Arc<BlobIndexCacheContext>,
+}
+
+impl FileIOCacheContext {
+    pub(crate) fn from_props(props: &HashMap<String, String>) -> 
crate::Result<Self> {
+        Ok(Self {
+            file_format_metadata_cache: 
FileFormatMetadataCacheContext::from_props(props)?,
+            blob_index_cache: BlobIndexCacheContext::from_props(props)?,
+        })
+    }
+}
+
 impl std::fmt::Debug for FileIO {
     fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
         f.debug_struct("FileIO")
@@ -168,6 +250,10 @@ impl std::fmt::Debug for FileIO {
                 "file_format_metadata_cache_max_bytes",
                 &self.file_format_metadata_cache.max_bytes(),
             )
+            .field(
+                "blob_index_cache_max_bytes",
+                &self.blob_index_cache.max_bytes(),
+            )
             .finish()
     }
 }
@@ -191,21 +277,31 @@ impl FileIO {
         Ok(self)
     }
 
-    /// Replace the storage backend with a provider, retaining the file cache.
+    /// Replace the storage backend with a provider. Caching requires the 
provider
+    /// to supply a stable storage namespace for each resolved path.
     ///
     /// Resolution is deferred to async operations, including for file handles
     /// subsequently created by [`Self::new_input`] and [`Self::new_output`].
     pub fn with_provider(mut self, provider: Arc<dyn FileIOProvider>) -> Self {
         self.backend = FileIOBackend::Provider(provider);
-        self.cache_namespace = next_file_io_cache_namespace();
         self
     }
 
-    #[cfg(test)]
     pub(crate) fn file_format_metadata_cache(&self) -> 
Arc<FileFormatMetadataCacheContext> {
         Arc::clone(&self.file_format_metadata_cache)
     }
 
+    pub(crate) fn blob_index_cache(&self) -> Arc<BlobIndexCacheContext> {
+        Arc::clone(&self.blob_index_cache)
+    }
+
+    pub(crate) fn cache_context(&self) -> FileIOCacheContext {
+        FileIOCacheContext {
+            file_format_metadata_cache: self.file_format_metadata_cache(),
+            blob_index_cache: self.blob_index_cache(),
+        }
+    }
+
     pub(crate) fn create_static(&self, path: &str) -> crate::Result<(Operator, 
String)> {
         let FileIOBackend::Storage(storage) = &self.backend else {
             return Err(Error::IoUnsupported {
@@ -223,12 +319,37 @@ impl FileIO {
         }
     }
 
+    async fn create_with_cache_path(
+        &self,
+        path: &str,
+    ) -> crate::Result<(Operator, String, Option<String>)> {
+        let (op, relative_path, namespace) = match &self.backend {
+            FileIOBackend::Provider(provider) => {
+                let (op, relative_path, namespace) =
+                    resolve_provider_with_cache_namespace(provider.as_ref(), 
path).await?;
+                (op, relative_path, namespace)
+            }
+            FileIOBackend::Storage(_) => {
+                let (op, relative_path) = self.create_static(path)?;
+                (
+                    op,
+                    relative_path,
+                    Some(self.cache_namespace_for_path(path)?),
+                )
+            }
+        };
+        let cache_path =
+            namespace.map(|namespace| cache_object_path(&namespace, &op, 
&relative_path));
+        Ok((op, relative_path, cache_path))
+    }
+
     fn file_source(&self, path: &str) -> crate::Result<FileSource> {
         match &self.backend {
             FileIOBackend::Provider(provider) => 
Ok(FileSource::Provider(provider.clone())),
             FileIOBackend::Storage(_) => {
                 let (op, relative_path) = self.create_static(path)?;
-                let cache_path = cache_object_path(&op, &relative_path);
+                let namespace = self.cache_namespace_for_path(path)?;
+                let cache_path = cache_object_path(&namespace, &op, 
&relative_path);
                 Ok(FileSource::Static {
                     op,
                     relative_path,
@@ -238,6 +359,24 @@ impl FileIO {
         }
     }
 
+    pub(crate) fn cache_namespace_for_path(&self, _path: &str) -> 
crate::Result<String> {
+        #[cfg(feature = "storage-azdls")]
+        if let FileIOBackend::Storage(storage) = &self.backend {
+            if let Storage::Azdls { config, .. } = storage.as_ref() {
+                let identity = super::azdls_operator_cache_key(config, _path)?;
+                return Ok(format!("{}\0{identity}", self.cache_namespace));
+            }
+        }
+        #[cfg(feature = "storage-hdfs")]
+        if let FileIOBackend::Storage(storage) = &self.backend {
+            if let Storage::Hdfs { config, .. } = storage.as_ref() {
+                let identity = super::hdfs_effective_name_node(config, _path)?;
+                return Ok(format!("{}\0{identity}", self.cache_namespace));
+            }
+        }
+        Ok(self.cache_namespace.to_string())
+    }
+
     #[cfg(test)]
     pub(crate) fn has_local_cache(&self) -> bool {
         self.cache.is_some()
@@ -288,8 +427,8 @@ impl FileIO {
         Ok(InputFile {
             source: self.file_source(path)?,
             path: path.to_string(),
-            cache_namespace: self.cache_namespace,
             file_format_metadata_cache: 
Arc::clone(&self.file_format_metadata_cache),
+            blob_index_cache: Arc::clone(&self.blob_index_cache),
             cache: self
                 .cache
                 .as_ref()
@@ -306,8 +445,8 @@ impl FileIO {
         Ok(OutputFile {
             source: self.file_source(path)?,
             path: path.to_string(),
-            cache_namespace: self.cache_namespace,
             file_format_metadata_cache: 
Arc::clone(&self.file_format_metadata_cache),
+            blob_index_cache: Arc::clone(&self.blob_index_cache),
             cache: self
                 .cache
                 .as_ref()
@@ -488,13 +627,15 @@ impl FileIO {
     ///
     /// Reference: 
<https://github.com/apache/paimon/blob/release-0.8.2/paimon-common/src/main/java/org/apache/paimon/fs/FileIO.java#L139>
     pub async fn delete_file(&self, path: &str) -> Result<()> {
-        let (op, relative_path) = self.create(path).await?;
-        let cache_path = cache_object_path(&op, relative_path.as_ref());
+        let (op, relative_path, cache_path) = 
self.create_with_cache_path(path).await?;
 
         op.delete(relative_path.as_ref()).await.map_err(|error| {
             Error::from_opendal_with_context(error, format!("Failed to delete 
file '{path}'"))
         })?;
-        if let Some(cache) = self.cache.as_ref().filter(|cache| 
cache.is_cacheable(path)) {
+        if let (Some(cache), Some(cache_path)) = (
+            self.cache.as_ref().filter(|cache| cache.is_cacheable(path)),
+            cache_path,
+        ) {
             cache.invalidate_path(&cache_path).await;
         }
 
@@ -505,8 +646,7 @@ impl FileIO {
     ///
     /// Reference: 
<https://github.com/apache/paimon/blob/release-0.8.2/paimon-common/src/main/java/org/apache/paimon/fs/FileIO.java#L139>
     pub async fn delete_dir(&self, path: &str) -> Result<()> {
-        let (op, relative_path) = self.create(path).await?;
-        let cache_path = cache_object_path(&op, relative_path.as_ref());
+        let (op, relative_path, cache_path) = 
self.create_with_cache_path(path).await?;
 
         op.delete_with(relative_path.as_ref())
             .recursive(true)
@@ -517,7 +657,7 @@ impl FileIO {
                     format!("Failed to delete directory '{path}'"),
                 )
             })?;
-        if let Some(cache) = &self.cache {
+        if let (Some(cache), Some(cache_path)) = (&self.cache, cache_path) {
             cache.invalidate_prefix(&cache_path).await;
         }
 
@@ -586,8 +726,8 @@ impl FileIO {
     ///
     /// Reference: 
<https://github.com/apache/paimon/blob/release-0.8.2/paimon-common/src/main/java/org/apache/paimon/fs/FileIO.java#L159>
     pub async fn rename(&self, src: &str, dst: &str) -> Result<()> {
-        let (op_src, relative_path_src) = self.create(src).await?;
-        let (op_dst, relative_path_dst) = self.create(dst).await?;
+        let (op_src, relative_path_src, cache_path_src) = 
self.create_with_cache_path(src).await?;
+        let (op_dst, relative_path_dst, cache_path_dst) = 
self.create_with_cache_path(dst).await?;
         if matches!(self.backend, FileIOBackend::Provider(_))
             && !Arc::ptr_eq(op_src.service(), op_dst.service())
         {
@@ -596,9 +736,6 @@ impl FileIO {
                     .to_string(),
             });
         }
-        let cache_path_src = cache_object_path(&op_src, 
relative_path_src.as_ref());
-        let cache_path_dst = cache_object_path(&op_dst, 
relative_path_dst.as_ref());
-
         op_src
             .rename(relative_path_src.as_ref(), relative_path_dst.as_ref())
             .await
@@ -609,8 +746,12 @@ impl FileIO {
                 )
             })?;
         if let Some(cache) = &self.cache {
-            cache.invalidate_prefix(&cache_path_src).await;
-            cache.invalidate_prefix(&cache_path_dst).await;
+            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;
+            }
         }
 
         Ok(())
@@ -626,6 +767,15 @@ async fn resolve_provider(
     Ok((op, relative_path))
 }
 
+async fn resolve_provider_with_cache_namespace(
+    provider: &dyn FileIOProvider,
+    path: &str,
+) -> crate::Result<(Operator, String, Option<String>)> {
+    let (op, relative_path, cache_namespace) = 
provider.create_with_cache_namespace(path).await?;
+    validate_provider_path(path, &relative_path)?;
+    Ok((op, relative_path, cache_namespace))
+}
+
 fn validate_provider_path(path: &str, relative_path: &str) -> Result<()> {
     // Filesystem paths retain their existing separator normalization. Object
     // keys must not silently resolve to another object through OpenDAL's path
@@ -676,10 +826,11 @@ fn status_path(base_path: &str, entry_path: &str) -> 
String {
     }
 }
 
-fn cache_object_path(op: &Operator, relative_path: &str) -> String {
+fn cache_object_path(namespace: &str, op: &Operator, relative_path: &str) -> 
String {
     let info = op.info();
     format!(
-        "{}\0{}\0{}\0{}",
+        "{}\0{}\0{}\0{}\0{}",
+        namespace,
         info.scheme(),
         info.name(),
         info.root(),
@@ -687,6 +838,29 @@ fn cache_object_path(op: &Operator, relative_path: &str) 
-> String {
     )
 }
 
+fn storage_cache_namespace(props: &HashMap<String, String>) -> Arc<str> {
+    // Credentials may rotate while the storage namespace stays unchanged.
+    // Endpoints identify storage without coupling cache reuse to credentials.
+    // The complete cache key already includes the operator's canonical scheme;
+    // hashing the builder scheme here would split equivalent storage aliases.
+    let mut endpoints = props
+        .iter()
+        .filter(|(key, _)| key.to_ascii_lowercase().ends_with("endpoint"))
+        .collect::<Vec<_>>();
+    endpoints.sort_unstable_by_key(|(key, _)| *key);
+
+    let mut digest = Sha256::new();
+    for (key, value) in endpoints {
+        let key = key.to_ascii_lowercase();
+        let value = value.trim_end_matches('/');
+        digest.update((key.len() as u64).to_le_bytes());
+        digest.update(key);
+        digest.update((value.len() as u64).to_le_bytes());
+        digest.update(value);
+    }
+    Arc::from(hex::encode(digest.finalize()))
+}
+
 /// Whether `path` begins with a Windows drive specifier such as `C:\` or 
`C:/`.
 pub(crate) fn looks_like_windows_drive_path(path: &str) -> bool {
     let bytes = path.as_bytes();
@@ -702,6 +876,7 @@ pub struct FileIOBuilder {
     props: HashMap<String, String>,
     cache: Option<Arc<LocalCache>>,
     file_format_metadata_cache: Option<Arc<FileFormatMetadataCacheContext>>,
+    blob_index_cache: Option<Arc<BlobIndexCacheContext>>,
     operator: Option<Operator>,
     provider: Option<Arc<dyn FileIOProvider>>,
 }
@@ -713,6 +888,7 @@ impl FileIOBuilder {
             props: HashMap::default(),
             cache: None,
             file_format_metadata_cache: None,
+            blob_index_cache: None,
             operator: None,
             provider: None,
         }
@@ -767,6 +943,7 @@ impl FileIOBuilder {
         self
     }
 
+    #[cfg(test)]
     pub(crate) fn with_file_format_metadata_cache(
         mut self,
         cache: Arc<FileFormatMetadataCacheContext>,
@@ -775,13 +952,25 @@ impl FileIOBuilder {
         self
     }
 
+    pub(crate) fn with_cache_context(mut self, cache: FileIOCacheContext) -> 
Self {
+        self.file_format_metadata_cache = 
Some(cache.file_format_metadata_cache);
+        self.blob_index_cache = Some(cache.blob_index_cache);
+        self
+    }
+
     pub fn build(mut self) -> crate::Result<FileIO> {
         let cache = self.cache.clone();
+        let cache_namespace = storage_cache_namespace(&self.props);
         let file_format_metadata_cache = self
             .file_format_metadata_cache
             .clone()
             .map(Ok)
             .unwrap_or_else(|| 
FileFormatMetadataCacheContext::from_props(&self.props))?;
+        let blob_index_cache = self
+            .blob_index_cache
+            .clone()
+            .map(Ok)
+            .unwrap_or_else(|| 
BlobIndexCacheContext::from_props(&self.props))?;
         let backend = if let Some(provider) = self.provider.take() {
             if self.operator.is_some() {
                 return Err(Error::ConfigInvalid {
@@ -794,9 +983,10 @@ impl FileIOBuilder {
         };
         Ok(FileIO {
             backend,
+            cache_namespace,
             cache,
-            cache_namespace: next_file_io_cache_namespace(),
             file_format_metadata_cache,
+            blob_index_cache,
         })
     }
 }
@@ -806,6 +996,7 @@ pub trait FileRead: Send + Sync + Unpin + 'static {
     async fn read(&self, range: Range<u64>) -> crate::Result<Bytes>;
 
     #[doc(hidden)]
+    #[deprecated(note = "BLOB index caching no longer uses numeric FileIO 
namespaces")]
     fn cache_namespace(&self) -> Option<usize> {
         None
     }
@@ -819,6 +1010,11 @@ pub trait FileRead: Send + Sync + Unpin + 'static {
     fn file_format_metadata_cache(&self) -> Option<&(dyn Any + Send + Sync)> {
         None
     }
+
+    #[doc(hidden)]
+    fn blob_index_cache(&self) -> Option<&(dyn Any + Send + Sync)> {
+        None
+    }
 }
 
 #[async_trait::async_trait]
@@ -831,15 +1027,15 @@ impl FileRead for opendal::Reader {
 enum InputFileReader {
     Direct {
         reader: opendal::Reader,
-        cache_namespace: usize,
-        cache_key: String,
+        cache_key: Option<String>,
         file_format_metadata_cache: Arc<FileFormatMetadataCacheContext>,
+        blob_index_cache: Arc<BlobIndexCacheContext>,
     },
     Cached {
         reader: CachedFileReader,
-        cache_namespace: usize,
         cache_key: String,
         file_format_metadata_cache: Arc<FileFormatMetadataCacheContext>,
+        blob_index_cache: Arc<BlobIndexCacheContext>,
     },
 }
 
@@ -854,7 +1050,8 @@ impl FileRead for InputFileReader {
 
     fn cache_key(&self) -> Option<&str> {
         match self {
-            Self::Direct { cache_key, .. } | Self::Cached { cache_key, .. } => 
Some(cache_key),
+            Self::Direct { cache_key, .. } => cache_key.as_deref(),
+            Self::Cached { cache_key, .. } => Some(cache_key),
         }
     }
 
@@ -871,15 +1068,15 @@ impl FileRead for InputFileReader {
         }
     }
 
-    fn cache_namespace(&self) -> Option<usize> {
-        Some(match self {
+    fn blob_index_cache(&self) -> Option<&(dyn Any + Send + Sync)> {
+        match self {
             Self::Direct {
-                cache_namespace, ..
+                blob_index_cache, ..
             }
             | Self::Cached {
-                cache_namespace, ..
-            } => *cache_namespace,
-        })
+                blob_index_cache, ..
+            } => Some(blob_index_cache.as_ref()),
+        }
     }
 }
 
@@ -997,18 +1194,20 @@ enum FileSource {
 }
 
 impl FileSource {
-    async fn resolve(&self, path: &str) -> crate::Result<(Operator, String, 
String)> {
+    async fn resolve(&self, path: &str) -> crate::Result<(Operator, String, 
Option<String>)> {
         match self {
             Self::Provider(provider) => {
-                let (op, relative_path) = resolve_provider(provider.as_ref(), 
path).await?;
-                let cache_path = cache_object_path(&op, &relative_path);
+                let (op, relative_path, resolved_namespace) =
+                    resolve_provider_with_cache_namespace(provider.as_ref(), 
path).await?;
+                let cache_path = resolved_namespace
+                    .map(|namespace| cache_object_path(&namespace, &op, 
&relative_path));
                 Ok((op, relative_path, cache_path))
             }
             Self::Static {
                 op,
                 relative_path,
                 cache_path,
-            } => Ok((op.clone(), relative_path.clone(), cache_path.clone())),
+            } => Ok((op.clone(), relative_path.clone(), 
Some(cache_path.clone()))),
         }
     }
 }
@@ -1017,8 +1216,8 @@ impl FileSource {
 pub struct InputFile {
     source: FileSource,
     path: String,
-    cache_namespace: usize,
     file_format_metadata_cache: Arc<FileFormatMetadataCacheContext>,
+    blob_index_cache: Arc<BlobIndexCacheContext>,
     cache: Option<Arc<LocalCache>>,
 }
 
@@ -1048,7 +1247,7 @@ impl InputFile {
 
     pub async fn read(&self) -> crate::Result<Bytes> {
         let (op, relative_path, cache_path) = 
self.source.resolve(&self.path).await?;
-        let Some(cache) = &self.cache else {
+        let (Some(cache), Some(cache_path)) = (&self.cache, cache_path) else {
             return Ok(op.read(&relative_path).await?.to_bytes());
         };
         let read_token = cache.read_token(&cache_path);
@@ -1068,33 +1267,33 @@ impl InputFile {
     pub async fn reader(&self) -> crate::Result<impl FileRead> {
         let (op, relative_path, cache_path) = 
self.source.resolve(&self.path).await?;
         let reader = op.reader(&relative_path).await?;
-        let Some(cache) = &self.cache else {
+        let (Some(cache), Some(cache_path)) = (&self.cache, 
cache_path.as_ref()) else {
             return Ok(InputFileReader::Direct {
                 reader,
-                cache_namespace: self.cache_namespace,
                 cache_key: cache_path,
                 file_format_metadata_cache: 
Arc::clone(&self.file_format_metadata_cache),
+                blob_index_cache: Arc::clone(&self.blob_index_cache),
             });
         };
-        let read_token = cache.read_token(&cache_path);
-        let size = if let Some(size) = cache.file_size(&cache_path, 
&read_token).await {
+        let read_token = cache.read_token(cache_path);
+        let size = if let Some(size) = cache.file_size(cache_path, 
&read_token).await {
             size
         } else {
             let size = op.stat(&relative_path).await?.content_length();
-            cache.put_file_size(&cache_path, size, &read_token).await;
+            cache.put_file_size(cache_path, size, &read_token).await;
             size
         };
         Ok(InputFileReader::Cached {
             reader: CachedFileReader::new_with_token(
                 Arc::new(reader),
-                &cache_path,
+                cache_path.as_str(),
                 size,
                 cache.clone(),
                 read_token,
             ),
-            cache_namespace: self.cache_namespace,
-            cache_key: cache_path,
+            cache_key: cache_path.clone(),
             file_format_metadata_cache: 
Arc::clone(&self.file_format_metadata_cache),
+            blob_index_cache: Arc::clone(&self.blob_index_cache),
         })
     }
 }
@@ -1103,8 +1302,8 @@ impl InputFile {
 pub struct OutputFile {
     source: FileSource,
     path: String,
-    cache_namespace: usize,
     file_format_metadata_cache: Arc<FileFormatMetadataCacheContext>,
+    blob_index_cache: Arc<BlobIndexCacheContext>,
     cache: Option<Arc<LocalCache>>,
 }
 
@@ -1123,8 +1322,8 @@ impl OutputFile {
         InputFile {
             source: self.source,
             path: self.path,
-            cache_namespace: self.cache_namespace,
             file_format_metadata_cache: self.file_format_metadata_cache,
+            blob_index_cache: self.blob_index_cache,
             cache,
         }
     }
@@ -1142,7 +1341,7 @@ impl OutputFile {
                 .chunk(8 * 1024 * 1024)
                 .await?,
         );
-        let Some(cache) = &self.cache else {
+        let (Some(cache), Some(cache_path)) = (&self.cache, cache_path) else {
             return Ok(writer);
         };
         Ok(Box::new(CacheInvalidatingWriter {
@@ -1163,7 +1362,7 @@ impl OutputFile {
                 .into_futures_async_write()
                 .compat_write(),
         );
-        let Some(cache) = &self.cache else {
+        let (Some(cache), Some(cache_path)) = (&self.cache, cache_path) else {
             return Ok(writer);
         };
         Ok(Box::new(CacheInvalidatingAsyncWriter {
@@ -1870,6 +2069,76 @@ mod input_output_test {
             .unwrap()
     }
 
+    #[test]
+    fn test_storage_cache_namespace_tracks_endpoint_not_credentials() {
+        let first = HashMap::from([
+            (
+                "s3.endpoint".to_string(),
+                "https://first.example".to_string(),
+            ),
+            ("s3.access-key".to_string(), "first-key".to_string()),
+        ]);
+        let rotated = HashMap::from([
+            (
+                "s3.endpoint".to_string(),
+                "https://first.example".to_string(),
+            ),
+            ("s3.access-key".to_string(), "rotated-key".to_string()),
+        ]);
+        let second = HashMap::from([(
+            "s3.endpoint".to_string(),
+            "https://second.example".to_string(),
+        )]);
+
+        assert_eq!(
+            storage_cache_namespace(&first),
+            storage_cache_namespace(&rotated)
+        );
+        assert_ne!(
+            storage_cache_namespace(&first),
+            storage_cache_namespace(&second)
+        );
+    }
+
+    #[cfg(all(feature = "storage-fs", feature = "storage-memory"))]
+    #[test]
+    fn test_cache_object_path_distinguishes_storage_schemes() {
+        let namespace = storage_cache_namespace(&HashMap::new());
+        let (fs_op, _) = setup_fs_file_io()
+            .create_static("file:/snapshot-1")
+            .unwrap();
+        let (memory_op, _) = setup_memory_file_io()
+            .create_static("memory:/snapshot-1")
+            .unwrap();
+        assert_ne!(
+            cache_object_path(&namespace, &fs_op, "snapshot-1"),
+            cache_object_path(&namespace, &memory_op, "snapshot-1")
+        );
+    }
+
+    #[cfg(feature = "storage-s3")]
+    #[test]
+    fn test_storage_cache_keys_match_for_s3_scheme_aliases() {
+        let mut expected_key = None;
+        for scheme in ["s3", "s3a", "S3", "S3A"] {
+            let file_io = FileIOBuilder::new(scheme)
+                .with_prop("s3.endpoint", "https://s3.example";)
+                .with_prop("s3.region", "us-east-1")
+                .build()
+                .unwrap();
+            let FileSource::Static { cache_path, .. } =
+                file_io.file_source("s3://bucket/snapshot-1").unwrap()
+            else {
+                panic!("expected built-in storage");
+            };
+            if let Some(expected_key) = &expected_key {
+                assert_eq!(&cache_path, expected_key, "{scheme}");
+            } else {
+                expected_key = Some(cache_path);
+            }
+        }
+    }
+
     async fn common_test_output_file_write_and_read(file_io: &FileIO, path: 
&str) {
         let output = file_io.new_output(path).unwrap();
         let mut writer = output.writer().await.unwrap();
@@ -2052,6 +2321,57 @@ mod input_output_test {
         );
     }
 
+    #[tokio::test]
+    async fn test_blob_index_cache_size_reaches_file_reader() {
+        let path = "memory:/blob-index-cache-size.blob";
+        let default = setup_memory_file_io();
+        default
+            .new_output(path)
+            .unwrap()
+            .write(Bytes::from_static(b"data"))
+            .await
+            .unwrap();
+        assert_eq!(
+            default
+                .new_input(path)
+                .unwrap()
+                .reader()
+                .await
+                .unwrap()
+                .blob_index_cache()
+                .unwrap()
+                .downcast_ref::<BlobIndexCacheContext>()
+                .unwrap()
+                .max_bytes(),
+            DEFAULT_BLOB_INDEX_CACHE_MAX_BYTES
+        );
+
+        let configured = FileIOBuilder::new("memory")
+            .with_prop(CatalogOptions::BLOB_INDEX_CACHE_MAX_SIZE, "1 mb")
+            .build()
+            .unwrap();
+        configured
+            .new_output(path)
+            .unwrap()
+            .write(Bytes::from_static(b"data"))
+            .await
+            .unwrap();
+        assert_eq!(
+            configured
+                .new_input(path)
+                .unwrap()
+                .reader()
+                .await
+                .unwrap()
+                .blob_index_cache()
+                .unwrap()
+                .downcast_ref::<BlobIndexCacheContext>()
+                .unwrap()
+                .max_bytes(),
+            1024 * 1024
+        );
+    }
+
     #[test]
     fn test_file_format_metadata_cache_size_rejects_invalid_value() {
         let error = FileIOBuilder::new("memory")
@@ -2066,6 +2386,17 @@ mod input_output_test {
             .contains(CatalogOptions::FILE_FORMAT_METADATA_CACHE_MAX_SIZE));
     }
 
+    #[test]
+    fn test_blob_index_cache_size_rejects_invalid_value() {
+        let error = FileIOBuilder::new("memory")
+            .with_prop(CatalogOptions::BLOB_INDEX_CACHE_MAX_SIZE, "invalid")
+            .build()
+            .unwrap_err();
+        assert!(error
+            .to_string()
+            .contains(CatalogOptions::BLOB_INDEX_CACHE_MAX_SIZE));
+    }
+
     #[tokio::test]
     async fn test_output_file_write_and_read_fs() {
         let file_io = setup_fs_file_io();
@@ -2208,6 +2539,94 @@ mod input_output_test {
         );
     }
 
+    #[cfg(not(windows))]
+    #[tokio::test]
+    async fn 
test_file_io_external_cache_invalidates_across_local_scheme_aliases() {
+        #[derive(Debug, Default)]
+        struct TestBlockCache(std::sync::Mutex<HashMap<(String, u64), Bytes>>);
+
+        #[async_trait::async_trait]
+        impl FileBlockCache for TestBlockCache {
+            async fn get(&self, path: &str, range: Range<u64>) -> 
Option<Bytes> {
+                self.0
+                    .lock()
+                    .unwrap()
+                    .get(&(path.to_string(), range.start))
+                    .filter(|data| data.len() as u64 == range.end - 
range.start)
+                    .cloned()
+            }
+
+            async fn put(&self, path: &str, offset: u64, data: Bytes) {
+                self.0
+                    .lock()
+                    .unwrap()
+                    .insert((path.to_string(), offset), data);
+            }
+
+            async fn invalidate_path(&self, path: &str) {
+                self.0.lock().unwrap().retain(|(key, _), _| key != path);
+            }
+
+            async fn invalidate_prefix(&self, prefix: &str) {
+                self.0
+                    .lock()
+                    .unwrap()
+                    .retain(|(key, _), _| !key.starts_with(prefix));
+            }
+        }
+
+        for reader_scheme in ["", "file", "fs", "FILE", "FS"] {
+            for writer_scheme in ["", "file", "fs", "FILE", "FS"] {
+                let source_directory = tempfile::tempdir().unwrap();
+                let source_path = source_directory.path().join("snapshot-1");
+                std::fs::write(&source_path, b"old metadata").unwrap();
+                let location = format!("file:{}", source_path.display());
+                let cache = Arc::new(TestBlockCache::default());
+                let reader_io = FileIOBuilder::new(reader_scheme)
+                    .build()
+                    .unwrap()
+                    .with_file_block_cache(cache.clone(), 64, "meta")
+                    .unwrap();
+                let writer_io = FileIOBuilder::new(writer_scheme)
+                    .build()
+                    .unwrap()
+                    .with_file_block_cache(cache.clone(), 64, "meta")
+                    .unwrap();
+                assert_eq!(
+                    reader_io
+                        .new_input(&location)
+                        .unwrap()
+                        .read()
+                        .await
+                        .unwrap(),
+                    Bytes::from_static(b"old metadata")
+                );
+                assert_eq!(cache.0.lock().unwrap().len(), 1);
+                writer_io
+                    .new_output(&location)
+                    .unwrap()
+                    .write(Bytes::from_static(b"new metadata"))
+                    .await
+                    .unwrap();
+                assert!(
+                    cache.0.lock().unwrap().is_empty(),
+                    "reader={reader_scheme:?}, writer={writer_scheme:?}"
+                );
+                assert_eq!(std::fs::read(&source_path).unwrap(), b"new 
metadata");
+                assert_eq!(
+                    reader_io
+                        .new_input(&location)
+                        .unwrap()
+                        .read()
+                        .await
+                        .unwrap(),
+                    Bytes::from_static(b"new metadata"),
+                    "reader={reader_scheme:?}, writer={writer_scheme:?}"
+                );
+            }
+        }
+    }
+
     #[cfg(not(windows))]
     #[tokio::test]
     async fn test_file_io_local_cache_invalidates_after_delete() {
diff --git a/crates/paimon/src/io/file_io/provider_tests.rs 
b/crates/paimon/src/io/file_io/provider_tests.rs
index 270dfcf9..cf949fd0 100644
--- a/crates/paimon/src/io/file_io/provider_tests.rs
+++ b/crates/paimon/src/io/file_io/provider_tests.rs
@@ -24,6 +24,7 @@ struct PrefixProvider {
     routes: Vec<(String, Operator)>,
     calls: AtomicUsize,
     reject: AtomicBool,
+    cacheable: AtomicBool,
 }
 
 #[async_trait::async_trait]
@@ -43,6 +44,18 @@ impl FileIOProvider for PrefixProvider {
         }
         RejectingProvider.create(path).await
     }
+
+    async fn create_with_cache_namespace(
+        &self,
+        path: &str,
+    ) -> Result<(Operator, String, Option<String>)> {
+        let (op, relative) = self.create(path).await?;
+        let namespace = self
+            .cacheable
+            .load(Ordering::SeqCst)
+            .then(|| format!("{:p}", Arc::as_ptr(op.service())));
+        Ok((op, relative, namespace))
+    }
 }
 
 fn memory_operator() -> Operator {
@@ -54,6 +67,7 @@ fn provider_io(routes: Vec<(String, Operator)>) -> (FileIO, 
Arc<PrefixProvider>)
         routes,
         calls: AtomicUsize::new(0),
         reject: AtomicBool::new(false),
+        cacheable: AtomicBool::new(false),
     });
     let io = FileIOBuilder::new("application-storage")
         .with_provider(provider.clone())
@@ -392,6 +406,24 @@ fn with_memory_cache(mut io: FileIO) -> FileIO {
     io
 }
 
+#[tokio::test]
+async fn provider_without_namespace_bypasses_caches() {
+    let op = memory_operator();
+    let (io, _) = provider_io(vec![("s3://bucket/".to_string(), op.clone())]);
+    let io = with_memory_cache(io);
+    let path = "s3://bucket/table/snapshot/snapshot-1";
+    let input = io.new_input(path).unwrap();
+    op.write("table/snapshot/snapshot-1", "first")
+        .await
+        .unwrap();
+    assert_eq!(input.reader().await.unwrap().cache_key(), None);
+    assert_eq!(input.read().await.unwrap(), "first");
+    op.write("table/snapshot/snapshot-1", "later")
+        .await
+        .unwrap();
+    assert_eq!(input.read().await.unwrap(), "later");
+}
+
 #[tokio::test]
 async fn provider_cache_isolates_buckets_and_invalidates_aliases() {
     use tokio::io::AsyncWriteExt;
@@ -403,6 +435,7 @@ async fn 
provider_cache_isolates_buckets_and_invalidates_aliases() {
         ("s3a://first/".to_string(), first.clone()),
         ("oss://second/".to_string(), second.clone()),
     ]);
+    provider.cacheable.store(true, Ordering::SeqCst);
     let io = with_memory_cache(io);
     let key = "table/snapshot/snapshot-1";
     let a = io.new_input(&format!("s3://first/{key}")).unwrap();
diff --git a/crates/paimon/src/io/storage_hdfs.rs 
b/crates/paimon/src/io/storage_hdfs.rs
index 8f40aea1..ab6fcbef 100644
--- a/crates/paimon/src/io/storage_hdfs.rs
+++ b/crates/paimon/src/io/storage_hdfs.rs
@@ -76,25 +76,27 @@ pub(crate) fn hdfs_config_parse(props: HashMap<String, 
String>) -> Result<HdfsNa
 ///
 /// Example path: "hdfs://namenode:8020/warehouse/db/table"
 pub(crate) fn hdfs_config_build(cfg: &HdfsNativeConfig, path: &str) -> 
Result<Operator> {
-    let url = Url::parse(path).map_err(|_| Error::ConfigInvalid {
-        message: format!("Invalid HDFS url: {path}"),
-    })?;
-
     let mut cfg = cfg.clone();
-
-    if cfg.name_node.is_none() {
-        let host = url.host_str().ok_or_else(|| Error::ConfigInvalid {
-            message: format!("Invalid HDFS url: {path}, missing name node 
host"),
-        })?;
-        let port_part = url.port().map(|p| 
format!(":{p}")).unwrap_or_default();
-        cfg.name_node = Some(format!("hdfs://{host}{port_part}"));
-    }
-
+    cfg.name_node = Some(hdfs_effective_name_node(&cfg, path)?);
     cfg.root = Some("/".to_string());
 
     Ok(Operator::from_config(cfg)?)
 }
 
+pub(crate) fn hdfs_effective_name_node(cfg: &HdfsNativeConfig, path: &str) -> 
Result<String> {
+    let url = Url::parse(path).map_err(|_| Error::ConfigInvalid {
+        message: format!("Invalid HDFS url: {path}"),
+    })?;
+    if let Some(name_node) = &cfg.name_node {
+        return Ok(name_node.clone());
+    }
+    let host = url.host_str().ok_or_else(|| Error::ConfigInvalid {
+        message: format!("Invalid HDFS url: {path}, missing name node host"),
+    })?;
+    let port_part = url.port().map(|p| format!(":{p}")).unwrap_or_default();
+    Ok(format!("hdfs://{host}{port_part}"))
+}
+
 #[cfg(test)]
 #[allow(deprecated)]
 mod tests {
@@ -147,6 +149,20 @@ mod tests {
         assert_eq!(op.info().scheme().to_string(), "hdfs-native");
     }
 
+    #[test]
+    fn test_hdfs_effective_name_node() {
+        let mut cfg = HdfsNativeConfig::default();
+        assert_eq!(
+            hdfs_effective_name_node(&cfg, 
"hdfs://cluster-a:8020/table/data.blob").unwrap(),
+            "hdfs://cluster-a:8020"
+        );
+        cfg.name_node = Some("hdfs://nameservice-b".to_string());
+        assert_eq!(
+            hdfs_effective_name_node(&cfg, 
"hdfs://logical-name/table/data.blob").unwrap(),
+            "hdfs://nameservice-b"
+        );
+    }
+
     #[test]
     fn test_hdfs_config_build_invalid_url() {
         let cfg = HdfsNativeConfig::default();
diff --git a/crates/paimon/src/table/data_file_reader.rs 
b/crates/paimon/src/table/data_file_reader.rs
index e608927c..3347bb12 100644
--- a/crates/paimon/src/table/data_file_reader.rs
+++ b/crates/paimon/src/table/data_file_reader.rs
@@ -108,10 +108,6 @@ impl FileRead for TimedFileRead {
         result
     }
 
-    fn cache_namespace(&self) -> Option<usize> {
-        self.inner.cache_namespace()
-    }
-
     fn cache_key(&self) -> Option<&str> {
         self.inner.cache_key()
     }
@@ -119,6 +115,10 @@ impl FileRead for TimedFileRead {
     fn file_format_metadata_cache(&self) -> Option<&(dyn std::any::Any + Send 
+ Sync)> {
         self.inner.file_format_metadata_cache()
     }
+
+    fn blob_index_cache(&self) -> Option<&(dyn std::any::Any + Send + Sync)> {
+        self.inner.blob_index_cache()
+    }
 }
 
 /// Reads data files through their format-specific readers.
@@ -2083,18 +2083,24 @@ mod tests {
     }
 
     #[tokio::test]
-    async fn timed_file_read_preserves_cache_namespace() {
+    async fn timed_file_read_preserves_cache_context() {
         let file_io = FileIOBuilder::new("memory").build().unwrap();
         let input = file_io.new_input("memory:/timed-file").unwrap();
         let reader = input.reader().await.unwrap();
-        let cache_namespace = reader.cache_namespace();
+        let blob_index_cache = file_io.blob_index_cache();
         let timed = TimedFileRead {
             inner: Box::new(reader),
             timing: Arc::new(DataFileReadTiming::default()),
         };
 
-        assert!(cache_namespace.is_some());
-        assert_eq!(timed.cache_namespace(), cache_namespace);
+        assert!(std::ptr::eq(
+            timed
+                .blob_index_cache()
+                .unwrap()
+                .downcast_ref::<crate::io::BlobIndexCacheContext>()
+                .unwrap(),
+            blob_index_cache.as_ref()
+        ));
     }
 
     #[test]
diff --git a/crates/paimon/src/table/mod.rs b/crates/paimon/src/table/mod.rs
index d9e958b6..f3e02e50 100644
--- a/crates/paimon/src/table/mod.rs
+++ b/crates/paimon/src/table/mod.rs
@@ -870,8 +870,7 @@ pub(crate) async fn rest_query_auth_table() -> Table {
     options.set("token.provider", "bear");
     options.set("token", "test_token");
     let api = std::sync::Arc::new(RESTApi::new(options.clone(), 
false).await.unwrap());
-    let metadata_cache =
-        
crate::io::FileFormatMetadataCacheContext::from_props(options.to_map()).unwrap();
+    let file_io_cache = 
crate::io::FileIOCacheContext::from_props(options.to_map()).unwrap();
     let table = query_auth_table();
     Table {
         rest_env: Some(RESTEnv::new(
@@ -881,7 +880,7 @@ pub(crate) async fn rest_query_auth_table() -> Table {
             options,
             false,
             None,
-            metadata_cache,
+            file_io_cache,
         )),
         ..table
     }
diff --git a/crates/paimon/src/table/rest_env.rs 
b/crates/paimon/src/table/rest_env.rs
index 7dfda992..cf3ad2a6 100644
--- a/crates/paimon/src/table/rest_env.rs
+++ b/crates/paimon/src/table/rest_env.rs
@@ -24,7 +24,7 @@ use crate::catalog::{Identifier, RESTTokenFileIO};
 use crate::common::{CatalogOptions, Options};
 use crate::error::Error;
 use crate::io::cache::{create_local_cache_with_namespace, LocalCache};
-use crate::io::{FileFormatMetadataCacheContext, FileIO};
+use crate::io::{FileIO, FileIOCacheContext};
 use crate::spec::{CoreOptions, TableSchema, PATH_OPTION};
 use crate::table::snapshot_commit::{RESTSnapshotCommit, SnapshotCommit};
 use crate::table::{ObjectTable, Table};
@@ -54,8 +54,7 @@ impl Table {
             .map(|v| v.eq_ignore_ascii_case("true"))
             .unwrap_or(false);
         let local_cache = create_local_cache_with_namespace(&rest_options, 
api.options())?;
-        let file_format_metadata_cache =
-            
FileFormatMetadataCacheContext::from_props(api.options().to_map())?;
+        let file_io_cache = 
FileIOCacheContext::from_props(api.options().to_map())?;
         RESTEnv::build_table(
             &identifier,
             response,
@@ -63,7 +62,7 @@ impl Table {
             rest_options,
             data_token_enabled,
             local_cache,
-            file_format_metadata_cache,
+            file_io_cache,
         )
         .await
     }
@@ -79,7 +78,7 @@ pub struct RESTEnv {
     options: Options,
     data_token_enabled: bool,
     local_cache: Option<Arc<LocalCache>>,
-    file_format_metadata_cache: Arc<FileFormatMetadataCacheContext>,
+    file_io_cache: FileIOCacheContext,
 }
 
 impl std::fmt::Debug for RESTEnv {
@@ -100,7 +99,7 @@ impl RESTEnv {
         options: Options,
         data_token_enabled: bool,
         local_cache: Option<Arc<LocalCache>>,
-        file_format_metadata_cache: Arc<FileFormatMetadataCacheContext>,
+        file_io_cache: FileIOCacheContext,
     ) -> Self {
         Self {
             identifier,
@@ -109,7 +108,7 @@ impl RESTEnv {
             options,
             data_token_enabled,
             local_cache,
-            file_format_metadata_cache,
+            file_io_cache,
         }
     }
 
@@ -201,7 +200,7 @@ impl RESTEnv {
             self.options.clone(),
             self.data_token_enabled,
             self.local_cache.clone(),
-            self.file_format_metadata_cache.clone(),
+            self.file_io_cache.clone(),
         )
         .await
     }
@@ -213,7 +212,7 @@ impl RESTEnv {
         options: Options,
         data_token_enabled: bool,
         local_cache: Option<Arc<LocalCache>>,
-        file_format_metadata_cache: Arc<FileFormatMetadataCacheContext>,
+        file_io_cache: FileIOCacheContext,
     ) -> Result<Table> {
         let response = Self::fetch_table_response(identifier, &api).await?;
         Self::build_table(
@@ -223,7 +222,7 @@ impl RESTEnv {
             options,
             data_token_enabled,
             local_cache,
-            file_format_metadata_cache,
+            file_io_cache,
         )
         .await
     }
@@ -245,7 +244,7 @@ impl RESTEnv {
         options: Options,
         data_token_enabled: bool,
         local_cache: Option<Arc<LocalCache>>,
-        file_format_metadata_cache: Arc<FileFormatMetadataCacheContext>,
+        file_io_cache: FileIOCacheContext,
     ) -> Result<Table> {
         let identifier = response_identifier(identifier, &response)?;
         let schema = response.schema.ok_or_else(|| Error::DataInvalid {
@@ -311,7 +310,7 @@ impl RESTEnv {
             &options,
             data_token_enabled && !is_external,
             local_cache.clone(),
-            file_format_metadata_cache.clone(),
+            file_io_cache.clone(),
         )
         .await?;
 
@@ -322,7 +321,7 @@ impl RESTEnv {
             options,
             data_token_enabled,
             local_cache,
-            file_format_metadata_cache,
+            file_io_cache,
         );
         let parsed_identifier = identifier.parsed_object_name()?;
         let branch = parsed_identifier.branch_or_default().to_string();
@@ -348,7 +347,7 @@ impl RESTEnv {
         options: Options,
         data_token_enabled: bool,
         local_cache: Option<Arc<LocalCache>>,
-        file_format_metadata_cache: Arc<FileFormatMetadataCacheContext>,
+        file_io_cache: FileIOCacheContext,
     ) -> Result<ObjectTable> {
         let identifier = response_identifier(identifier, &response)?;
         let schema = response.schema.ok_or_else(|| Error::DataInvalid {
@@ -392,7 +391,7 @@ impl RESTEnv {
             &options,
             data_token_enabled && !is_external,
             local_cache,
-            file_format_metadata_cache,
+            file_io_cache,
         )
         .await?;
 
@@ -406,7 +405,7 @@ impl RESTEnv {
         options: &Options,
         use_data_token: bool,
         local_cache: Option<Arc<LocalCache>>,
-        file_format_metadata_cache: Arc<FileFormatMetadataCacheContext>,
+        file_io_cache: FileIOCacheContext,
     ) -> Result<FileIO> {
         if use_data_token {
             return Arc::new(RESTTokenFileIO::new(
@@ -415,14 +414,14 @@ impl RESTEnv {
                 options.clone(),
                 api,
                 local_cache,
-                file_format_metadata_cache,
+                file_io_cache,
             ))
             .build_file_io()
             .await;
         }
 
         let mut builder = 
FileIO::from_path(path)?.with_props(options.to_map());
-        builder = 
builder.with_file_format_metadata_cache(file_format_metadata_cache);
+        builder = builder.with_cache_context(file_io_cache);
         if let Some(local_cache) = local_cache {
             builder = builder.with_local_cache(local_cache);
         }
@@ -562,7 +561,7 @@ mod tests {
         );
         let local_cache = create_local_cache(&options).unwrap();
         let api = Arc::new(RESTApi::new(options.clone(), 
false).await.unwrap());
-        let metadata_cache = 
FileFormatMetadataCacheContext::from_props(options.to_map()).unwrap();
+        let file_io_cache = 
FileIOCacheContext::from_props(options.to_map()).unwrap();
 
         let rest_env = RESTEnv::new(
             Identifier::new("database", "table"),
@@ -571,7 +570,7 @@ mod tests {
             options,
             false,
             local_cache,
-            metadata_cache,
+            file_io_cache,
         );
 
         assert!(rest_env.has_local_cache());
@@ -586,7 +585,7 @@ mod tests {
         options.set(CatalogOptions::TOKEN_PROVIDER, "bear");
         options.set(CatalogOptions::TOKEN, "test-token");
         let api = Arc::new(RESTApi::new(options.clone(), 
false).await.unwrap());
-        let metadata_cache = 
FileFormatMetadataCacheContext::from_props(options.to_map()).unwrap();
+        let file_io_cache = 
FileIOCacheContext::from_props(options.to_map()).unwrap();
         let identifier = Identifier::new("database", "table");
 
         let first = RESTEnv::build_file_io(
@@ -596,7 +595,7 @@ mod tests {
             &options,
             false,
             None,
-            metadata_cache.clone(),
+            file_io_cache.clone(),
         )
         .await
         .unwrap();
@@ -607,7 +606,7 @@ mod tests {
             &options,
             false,
             None,
-            metadata_cache,
+            file_io_cache,
         )
         .await
         .unwrap();
@@ -616,5 +615,9 @@ mod tests {
             &first.file_format_metadata_cache(),
             &second.file_format_metadata_cache()
         ));
+        assert!(Arc::ptr_eq(
+            &first.blob_index_cache(),
+            &second.blob_index_cache()
+        ));
     }
 }
diff --git a/crates/paimon/src/table/snapshot_manager.rs 
b/crates/paimon/src/table/snapshot_manager.rs
index fc1b681e..1ca5344d 100644
--- a/crates/paimon/src/table/snapshot_manager.rs
+++ b/crates/paimon/src/table/snapshot_manager.rs
@@ -586,8 +586,7 @@ mod tests {
         let server = tokio::spawn(async move { axum::serve(listener, 
app).await.unwrap() });
         let api = Arc::new(RESTApi::new(options.clone(), 
false).await.unwrap());
         let id = Identifier::new("database", "table");
-        let metadata_cache =
-            
crate::io::FileFormatMetadataCacheContext::from_props(options.to_map()).unwrap();
+        let file_io_cache = 
crate::io::FileIOCacheContext::from_props(options.to_map()).unwrap();
         let env = crate::table::RESTEnv::new(
             id.clone(),
             "uuid".into(),
@@ -595,7 +594,7 @@ mod tests {
             options,
             false,
             None,
-            metadata_cache,
+            file_io_cache,
         );
         let (io, manager) = setup("/rest-table").await;
         manager.commit_snapshot(&test_snapshot(2)).await.unwrap();
diff --git a/crates/paimon/src/table/table_commit/parity_tests.rs 
b/crates/paimon/src/table/table_commit/parity_tests.rs
index 3c2ecbac..e4aa339c 100644
--- a/crates/paimon/src/table/table_commit/parity_tests.rs
+++ b/crates/paimon/src/table/table_commit/parity_tests.rs
@@ -614,8 +614,7 @@ async fn 
rest_commit_uses_catalog_snapshot_schema_and_retry_identity() {
     let server = tokio::spawn(async move { axum::serve(listener, 
app).await.unwrap() });
     let api = Arc::new(RESTApi::new(options.clone(), false).await.unwrap());
     let identifier = Identifier::new("database", "table");
-    let metadata_cache =
-        
crate::io::FileFormatMetadataCacheContext::from_props(options.to_map()).unwrap();
+    let file_io_cache = 
crate::io::FileIOCacheContext::from_props(options.to_map()).unwrap();
     let env = crate::table::RESTEnv::new(
         identifier.clone(),
         "uuid".into(),
@@ -623,7 +622,7 @@ async fn 
rest_commit_uses_catalog_snapshot_schema_and_retry_identity() {
         options,
         false,
         None,
-        metadata_cache,
+        file_io_cache,
     );
     let table = Table::new(
         io.clone(),
diff --git a/crates/paimon/src/table/table_commit/recovery_tests.rs 
b/crates/paimon/src/table/table_commit/recovery_tests.rs
index ffaaae51..32b5d3ec 100644
--- a/crates/paimon/src/table/table_commit/recovery_tests.rs
+++ b/crates/paimon/src/table/table_commit/recovery_tests.rs
@@ -546,8 +546,7 @@ async fn 
rest_delete_writer_pins_catalog_snapshot_and_preserves_vectors() {
     let server = tokio::spawn(async move { axum::serve(listener, 
app).await.unwrap() });
     let api = Arc::new(RESTApi::new(options.clone(), false).await.unwrap());
     let identifier = Identifier::new("database", "table");
-    let metadata_cache =
-        
crate::io::FileFormatMetadataCacheContext::from_props(options.to_map()).unwrap();
+    let file_io_cache = 
crate::io::FileIOCacheContext::from_props(options.to_map()).unwrap();
     let env = crate::table::RESTEnv::new(
         identifier.clone(),
         "uuid".into(),
@@ -555,7 +554,7 @@ async fn 
rest_delete_writer_pins_catalog_snapshot_and_preserves_vectors() {
         options,
         false,
         None,
-        metadata_cache,
+        file_io_cache,
     );
     let table = Table::new(io.clone(), identifier, path.into(), schema, 
Some(env));
     let mut commit = TableCommit::new(table.clone(), "rest-writer".into());
diff --git a/docs/src/getting-started.md b/docs/src/getting-started.md
index 0993d198..2d05ec11 100644
--- a/docs/src/getting-started.md
+++ b/docs/src/getting-started.md
@@ -362,6 +362,12 @@ 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.
 
+### BLOB Index Cache
+
+Decoded `.blob` indexes are cached per catalog. Set
+`cache.blob-index.max-size` to control the budget (default `64 MiB`; `0`
+disables caching). BLOB payloads are not cached.
+
 ### Manage Databases
 
 ```rust

Reply via email to