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 2073ce55 feat(core): protect local metadata and global index cache 
blocks (#1184)
2073ce55 is described below

commit 2073ce554b44d8ef72a437ce6d6ad4ea0df673e7
Author: XiaoHongbo <[email protected]>
AuthorDate: Tue Oct 20 21:30:27 2026 +0800

    feat(core): protect local metadata and global index cache blocks (#1184)
---
 crates/paimon/src/common/options.rs        |   8 ++
 crates/paimon/src/io/cache/disk.rs         | 153 +++++++++++++++++++++++------
 crates/paimon/src/io/cache/memory.rs       | 108 +++++++++++++++++---
 crates/paimon/src/io/cache/mod.rs          |  62 ++++++++++--
 crates/paimon/src/io/cache/priority_lru.rs | 145 +++++++++++++++++++++++++++
 crates/paimon/src/io/cache/reader.rs       |  40 ++++++++
 docs/src/getting-started.md                |   4 +
 7 files changed, 472 insertions(+), 48 deletions(-)

diff --git a/crates/paimon/src/common/options.rs 
b/crates/paimon/src/common/options.rs
index 86391377..c75f1954 100644
--- a/crates/paimon/src/common/options.rs
+++ b/crates/paimon/src/common/options.rs
@@ -85,6 +85,10 @@ impl CatalogOptions {
     /// Maximum total size of the local block cache.
     pub const LOCAL_CACHE_MAX_SIZE: &'static str = "local-cache.max-size";
 
+    /// Fraction of the local cache protected for metadata and global indexes.
+    pub const LOCAL_CACHE_HIGH_PRIORITY_POOL_RATIO: &'static str =
+        "local-cache.high-priority-pool-ratio";
+
     /// Block size used by the local cache.
     pub const LOCAL_CACHE_BLOCK_SIZE: &'static str = "local-cache.block-size";
 
@@ -293,6 +297,10 @@ mod tests {
         assert_eq!(CatalogOptions::LOCAL_CACHE_ENABLED, "local-cache.enabled");
         assert_eq!(CatalogOptions::LOCAL_CACHE_DIR, "local-cache.dir");
         assert_eq!(CatalogOptions::LOCAL_CACHE_MAX_SIZE, 
"local-cache.max-size");
+        assert_eq!(
+            CatalogOptions::LOCAL_CACHE_HIGH_PRIORITY_POOL_RATIO,
+            "local-cache.high-priority-pool-ratio"
+        );
         assert_eq!(
             CatalogOptions::LOCAL_CACHE_BLOCK_SIZE,
             "local-cache.block-size"
diff --git a/crates/paimon/src/io/cache/disk.rs 
b/crates/paimon/src/io/cache/disk.rs
index 72cb84f9..b731e303 100644
--- a/crates/paimon/src/io/cache/disk.rs
+++ b/crates/paimon/src/io/cache/disk.rs
@@ -16,7 +16,6 @@
 // under the License.
 
 use bytes::Bytes;
-use indexmap::IndexMap;
 use sha2::{Digest, Sha256};
 use std::collections::{HashMap, HashSet};
 use std::io::Read;
@@ -24,6 +23,9 @@ use std::path::{Path, PathBuf};
 use std::sync::{Arc, Mutex, OnceLock, Weak};
 use tokio::io::AsyncWriteExt;
 
+#[cfg(test)]
+use super::priority_lru::DEFAULT_HIGH_PRIORITY_POOL_RATIO;
+use super::priority_lru::{protected_limit, PriorityLru};
 use super::state::{BlockKey, CacheCoordinator, LogicalPath};
 
 const CACHE_MAGIC: &[u8; 8] = b"PAIMONLC";
@@ -64,24 +66,47 @@ pub(super) struct DiskCache {
     coordinator: Arc<CacheCoordinator>,
 }
 
-#[derive(Debug, Default)]
+#[derive(Debug)]
 struct CacheState {
-    entries: IndexMap<BlockKey, u64>,
+    entries: PriorityLru<u64>,
     paths: HashMap<LogicalPath, HashSet<BlockKey>>,
     current_size: u64,
     max_size: Option<u64>,
+    high_priority_ratio: f64,
+}
+
+impl CacheState {
+    fn new(max_size: Option<u64>, high_priority_ratio: f64) -> Self {
+        Self {
+            entries: PriorityLru::new(protected_limit(max_size, 
high_priority_ratio)),
+            paths: HashMap::new(),
+            current_size: 0,
+            max_size,
+            high_priority_ratio,
+        }
+    }
 }
 
 impl DiskCache {
     #[cfg(test)]
     pub(super) fn new(root: impl AsRef<Path>, max_size: Option<u64>) -> 
crate::Result<Self> {
+        Self::new_with_ratio(root, max_size, DEFAULT_HIGH_PRIORITY_POOL_RATIO)
+    }
+
+    #[cfg(test)]
+    pub(super) fn new_with_ratio(
+        root: impl AsRef<Path>,
+        max_size: Option<u64>,
+        ratio: f64,
+    ) -> crate::Result<Self> {
         let root = prepare_cache_root(root.as_ref())?;
-        Ok(Self::new_resolved(root, max_size))
+        Ok(Self::new_resolved(root, max_size, ratio))
     }
 
     pub(super) fn shared(
         root: impl AsRef<Path>,
         max_size: Option<u64>,
+        ratio: f64,
     ) -> crate::Result<Arc<Self>> {
         let root = prepare_cache_root(root.as_ref())?;
         let mut registry = disk_cache_registry()
@@ -91,34 +116,39 @@ impl DiskCache {
             registry.retain(|_, cache| cache.strong_count() > 0);
         }
         if let Some(cache) = registry.get(&root).and_then(Weak::upgrade) {
-            cache.tighten_max_size(max_size);
+            cache.tighten_max_size(max_size, ratio)?;
             return Ok(cache);
         }
 
-        let cache = Arc::new(Self::new_resolved(root.clone(), max_size));
+        let cache = Arc::new(Self::new_resolved(root.clone(), max_size, 
ratio));
         registry.insert(root, Arc::downgrade(&cache));
         Ok(cache)
     }
 
-    fn new_resolved(root: PathBuf, max_size: Option<u64>) -> Self {
+    fn new_resolved(root: PathBuf, max_size: Option<u64>, ratio: f64) -> Self {
         Self {
             root,
-            state: Mutex::new(CacheState {
-                max_size,
-                ..CacheState::default()
-            }),
+            state: Mutex::new(CacheState::new(max_size, ratio)),
             recovered: tokio::sync::OnceCell::new(),
             coordinator: Arc::new(CacheCoordinator::default()),
         }
     }
 
-    fn tighten_max_size(&self, requested: Option<u64>) {
+    fn tighten_max_size(&self, requested: Option<u64>, ratio: f64) -> 
crate::Result<()> {
         let mut state = self.state.lock().unwrap_or_else(|error| 
error.into_inner());
+        if state.high_priority_ratio != ratio {
+            return Err(crate::Error::ConfigInvalid {
+                message: "Local caches using the same directory must use the 
same local-cache.high-priority-pool-ratio".into(),
+            });
+        }
         state.max_size = match (state.max_size, requested) {
             (None, requested) => requested,
             (current, None) => current,
             (Some(current), Some(requested)) => Some(current.min(requested)),
         };
+        let limit = protected_limit(state.max_size, ratio);
+        state.entries.set_protected_limit(limit);
+        Ok(())
     }
 
     pub(super) async fn ensure_recovered(&self) {
@@ -141,7 +171,7 @@ impl DiskCache {
                             .state
                             .lock()
                             .unwrap_or_else(|error| error.into_inner());
-                        for (key, encoded_size) in recovered.entries {
+                        for (key, encoded_size) in recovered {
                             insert_state_entry(&mut state, key, encoded_size);
                         }
                     }
@@ -368,11 +398,7 @@ impl DiskCache {
 
     fn touch_entry(&self, key: &BlockKey) -> bool {
         let mut state = self.state.lock().unwrap_or_else(|error| 
error.into_inner());
-        let Some(encoded_size) = state.entries.shift_remove(key) else {
-            return false;
-        };
-        state.entries.insert(key.clone(), encoded_size);
-        true
+        state.entries.touch(key).is_some()
     }
 
     fn forget_entry(&self, key: &BlockKey) {
@@ -400,7 +426,7 @@ fn collect_evictions(state: &mut CacheState) -> 
Vec<BlockKey> {
     let mut to_evict = Vec::new();
     if let Some(max_size) = state.max_size {
         while state.current_size > max_size {
-            let Some((eldest, size)) = state.entries.shift_remove_index(0) 
else {
+            let Some((eldest, _, size)) = state.entries.pop_lru() else {
                 break;
             };
             state.current_size = state.current_size.saturating_sub(size);
@@ -425,7 +451,10 @@ fn logical_path_matches_prefix(path: &LogicalPath, 
namespace: &str, prefix: &str
 }
 
 fn insert_state_entry(state: &mut CacheState, key: BlockKey, encoded_size: 
u64) {
-    if let Some(previous_size) = state.entries.shift_remove(&key) {
+    if let Some((_, previous_size)) = state
+        .entries
+        .insert(key.clone(), encoded_size, encoded_size)
+    {
         state.current_size = state.current_size.saturating_sub(previous_size);
     }
     state
@@ -433,12 +462,11 @@ fn insert_state_entry(state: &mut CacheState, key: 
BlockKey, encoded_size: u64)
         .entry(logical_path(&key))
         .or_default()
         .insert(key.clone());
-    state.entries.insert(key, encoded_size);
     state.current_size = state.current_size.saturating_add(encoded_size);
 }
 
 fn remove_state_entry(state: &mut CacheState, key: &BlockKey) {
-    if let Some(encoded_size) = state.entries.shift_remove(key) {
+    if let Some((_, encoded_size)) = state.entries.remove(key) {
         state.current_size = state.current_size.saturating_sub(encoded_size);
         remove_path_index_entry(state, key);
     }
@@ -516,21 +544,21 @@ fn cleanup_temporary_files(directory: &Path) -> 
std::io::Result<()> {
     Ok(())
 }
 
-fn scan_existing_blocks(root: &Path) -> CacheState {
+fn scan_existing_blocks(root: &Path) -> Vec<(BlockKey, u64)> {
     let mut discovered = Vec::new();
     collect_cache_files(root, &mut discovered);
     discovered.sort_by_key(|(modified, _, _, _)| *modified);
 
-    let mut state = CacheState::default();
+    let mut entries = Vec::with_capacity(discovered.len());
     for (_, path, key, encoded_size) in discovered {
         if root.join(key.cache_relative_path()) != path {
             let _ = std::fs::remove_file(path);
             continue;
         }
-        insert_state_entry(&mut state, key, encoded_size);
+        entries.push((key, encoded_size));
     }
 
-    state
+    entries
 }
 
 fn collect_cache_files(
@@ -872,8 +900,14 @@ mod tests {
         let payload = Bytes::from_static(b"data");
         let one_block_size = encode_block(&first_key, &payload).len() as u64;
 
-        let first = DiskCache::shared(directory.path(), None).unwrap();
-        let second = DiskCache::shared(directory.path().join("."), 
Some(one_block_size)).unwrap();
+        let first =
+            DiskCache::shared(directory.path(), None, 
DEFAULT_HIGH_PRIORITY_POOL_RATIO).unwrap();
+        let second = DiskCache::shared(
+            directory.path().join("."),
+            Some(one_block_size),
+            DEFAULT_HIGH_PRIORITY_POOL_RATIO,
+        )
+        .unwrap();
 
         assert!(Arc::ptr_eq(&first, &second));
         first.put_block(&first_key, payload.clone()).await;
@@ -992,6 +1026,69 @@ mod tests {
         assert_eq!(cache.get_block(&second).await, Some(payload));
     }
 
+    #[tokio::test]
+    async fn test_disk_cache_protects_global_index_from_data_scan() {
+        let directory = tempfile::tempdir().unwrap();
+        let high = BlockKey::new("global-index-1.index", 4, 0);
+        let payload = Bytes::from_static(b"data");
+        let one_block_size = encode_block(&high, &payload).len() as u64;
+        let cache = DiskCache::new(directory.path(), Some(4 * 
one_block_size)).unwrap();
+        cache.put_block(&high, payload.clone()).await;
+        for index in 1..=4 {
+            cache
+                .put_block(
+                    &BlockKey::new(format!("data-{index}"), 4, 0),
+                    payload.clone(),
+                )
+                .await;
+        }
+
+        assert_eq!(cache.get_block(&high).await, Some(payload));
+        assert_eq!(cache.get_block(&BlockKey::new("data-1", 4, 0)).await, 
None);
+    }
+
+    #[tokio::test]
+    async fn test_disk_cache_recovery_preserves_priority() {
+        let directory = tempfile::tempdir().unwrap();
+        let high = BlockKey::new("global-index-1.index", 4, 0);
+        let payload = Bytes::from_static(b"data");
+        let one_high_block = encode_block(&high, &payload).len() as u64;
+        let initial = DiskCache::new(directory.path(), None).unwrap();
+        initial.put_block(&high, payload.clone()).await;
+        for index in 1..=4 {
+            initial
+                .put_block(
+                    &BlockKey::new(format!("data-{index}"), 4, 0),
+                    payload.clone(),
+                )
+                .await;
+        }
+        drop(initial);
+
+        let recovered = DiskCache::new(directory.path(), Some(4 * 
one_high_block)).unwrap();
+        recovered.ensure_recovered().await;
+        assert_eq!(recovered.get_block(&high).await, Some(payload));
+        let mut data_misses = 0;
+        for index in 1..=4 {
+            if recovered
+                .get_block(&BlockKey::new(format!("data-{index}"), 4, 0))
+                .await
+                .is_none()
+            {
+                data_misses += 1;
+            }
+        }
+        assert!(data_misses >= 1);
+    }
+
+    #[test]
+    fn test_disk_cache_shared_directory_rejects_different_priority_ratio() {
+        let directory = tempfile::tempdir().unwrap();
+        let _first = DiskCache::shared(directory.path(), Some(1024), 
0.25).unwrap();
+        let error = DiskCache::shared(directory.path(), Some(1024), 
0.5).unwrap_err();
+        assert!(matches!(error, crate::Error::ConfigInvalid { .. }));
+    }
+
     #[tokio::test]
     async fn test_disk_cache_lru_hit_refreshes_access_order() {
         let directory = tempfile::tempdir().unwrap();
diff --git a/crates/paimon/src/io/cache/memory.rs 
b/crates/paimon/src/io/cache/memory.rs
index c50cd91a..a57790d1 100644
--- a/crates/paimon/src/io/cache/memory.rs
+++ b/crates/paimon/src/io/cache/memory.rs
@@ -16,9 +16,11 @@
 // under the License.
 
 use bytes::Bytes;
-use lru::LruCache;
 use std::sync::Mutex;
 
+#[cfg(test)]
+use super::priority_lru::DEFAULT_HIGH_PRIORITY_POOL_RATIO;
+use super::priority_lru::{protected_limit, PriorityLru};
 use super::state::BlockKey;
 
 #[derive(Debug)]
@@ -28,16 +30,21 @@ pub(super) struct MemoryCache {
 
 #[derive(Debug)]
 struct MemoryState {
-    entries: LruCache<BlockKey, Bytes>,
+    entries: PriorityLru<Bytes>,
     current_size: u64,
     max_size: Option<u64>,
 }
 
 impl MemoryCache {
+    #[cfg(test)]
     pub(super) fn new(max_size: Option<u64>) -> Self {
+        Self::with_ratio(max_size, DEFAULT_HIGH_PRIORITY_POOL_RATIO)
+    }
+
+    pub(super) fn with_ratio(max_size: Option<u64>, ratio: f64) -> Self {
         Self {
             state: Mutex::new(MemoryState {
-                entries: LruCache::unbounded(),
+                entries: PriorityLru::new(protected_limit(max_size, ratio)),
                 current_size: 0,
                 max_size,
             }),
@@ -46,7 +53,7 @@ impl MemoryCache {
 
     pub(super) fn get_block(&self, key: &BlockKey) -> Option<Bytes> {
         let mut state = self.state.lock().unwrap_or_else(|error| 
error.into_inner());
-        state.entries.get(key).cloned()
+        state.entries.touch(key).cloned()
     }
 
     pub(super) fn put_block(&self, key: &BlockKey, payload: Bytes) {
@@ -58,25 +65,25 @@ impl MemoryCache {
         {
             return;
         }
-        if let Some(previous) = state.entries.put(key.clone(), payload) {
-            state.current_size = 
state.current_size.saturating_sub(previous.len() as u64);
+        if let Some((_, previous_size)) = state.entries.insert(key.clone(), 
payload, payload_size) {
+            state.current_size = 
state.current_size.saturating_sub(previous_size);
         }
         state.current_size = state.current_size.saturating_add(payload_size);
         while state
             .max_size
             .is_some_and(|max_size| state.current_size > max_size)
         {
-            let Some((_, payload)) = state.entries.pop_lru() else {
+            let Some((_, _, size)) = state.entries.pop_lru() else {
                 break;
             };
-            state.current_size = 
state.current_size.saturating_sub(payload.len() as u64);
+            state.current_size = state.current_size.saturating_sub(size);
         }
     }
 
     pub(super) fn remove_block(&self, key: &BlockKey) {
         let mut state = self.state.lock().unwrap_or_else(|error| 
error.into_inner());
-        if let Some(payload) = state.entries.pop(key) {
-            state.current_size = 
state.current_size.saturating_sub(payload.len() as u64);
+        if let Some((_, size)) = state.entries.remove(key) {
+            state.current_size = state.current_size.saturating_sub(size);
         }
     }
 
@@ -93,14 +100,13 @@ impl MemoryCache {
         let mut state = self.state.lock().unwrap_or_else(|error| 
error.into_inner());
         let keys = state
             .entries
-            .iter()
-            .map(|(key, _)| key)
+            .keys()
             .filter(|key| matches(key))
             .cloned()
             .collect::<Vec<_>>();
         for key in keys {
-            if let Some(payload) = state.entries.pop(&key) {
-                state.current_size = 
state.current_size.saturating_sub(payload.len() as u64);
+            if let Some((_, size)) = state.entries.remove(&key) {
+                state.current_size = state.current_size.saturating_sub(size);
             }
         }
     }
@@ -136,4 +142,78 @@ mod tests {
 
         assert_eq!(cache.get_block(&key), None);
     }
+
+    #[test]
+    fn test_memory_cache_protects_metadata_from_data_scan() {
+        let cache = MemoryCache::new(Some(16));
+        let metadata = BlockKey::new("snapshot-1", 4, 0);
+        cache.put_block(&metadata, Bytes::from_static(b"meta"));
+        for index in 1..=4 {
+            cache.put_block(
+                &BlockKey::new(format!("data-{index}"), 4, 0),
+                Bytes::from_static(b"data"),
+            );
+        }
+
+        assert_eq!(
+            cache.get_block(&metadata),
+            Some(Bytes::from_static(b"meta"))
+        );
+        assert_eq!(cache.get_block(&BlockKey::new("data-1", 4, 0)), None);
+    }
+
+    #[test]
+    fn test_memory_cache_high_priority_uses_idle_capacity() {
+        let cache = MemoryCache::new(Some(16));
+        for index in 1..=5 {
+            cache.put_block(
+                &BlockKey::new(format!("snapshot-{index}"), 4, 0),
+                Bytes::from_static(b"meta"),
+            );
+        }
+
+        assert_eq!(cache.get_block(&BlockKey::new("snapshot-1", 4, 0)), None);
+        for index in 2..=5 {
+            assert_eq!(
+                cache.get_block(&BlockKey::new(format!("snapshot-{index}"), 4, 
0)),
+                Some(Bytes::from_static(b"meta"))
+            );
+        }
+    }
+
+    #[test]
+    fn test_memory_cache_zero_ratio_restores_single_lru() {
+        let cache = MemoryCache::with_ratio(Some(16), 0.0);
+        let metadata = BlockKey::new("snapshot-1", 4, 0);
+        cache.put_block(&metadata, Bytes::from_static(b"meta"));
+        for index in 1..=4 {
+            cache.put_block(
+                &BlockKey::new(format!("data-{index}"), 4, 0),
+                Bytes::from_static(b"data"),
+            );
+        }
+        assert_eq!(cache.get_block(&metadata), None);
+    }
+
+    #[test]
+    fn test_memory_cache_high_priority_overflow_competes_with_data() {
+        let cache = MemoryCache::new(Some(16));
+        for index in 1..=2 {
+            cache.put_block(
+                &BlockKey::new(format!("snapshot-{index}"), 4, 0),
+                Bytes::from_static(b"meta"),
+            );
+        }
+        for index in 1..=3 {
+            cache.put_block(
+                &BlockKey::new(format!("data-{index}"), 4, 0),
+                Bytes::from_static(b"data"),
+            );
+        }
+        assert_eq!(cache.get_block(&BlockKey::new("snapshot-1", 4, 0)), None);
+        assert_eq!(
+            cache.get_block(&BlockKey::new("snapshot-2", 4, 0)),
+            Some(Bytes::from_static(b"meta"))
+        );
+    }
 }
diff --git a/crates/paimon/src/io/cache/mod.rs 
b/crates/paimon/src/io/cache/mod.rs
index 74c636d0..42eb32af 100644
--- a/crates/paimon/src/io/cache/mod.rs
+++ b/crates/paimon/src/io/cache/mod.rs
@@ -18,11 +18,13 @@
 mod disk;
 mod file_type;
 mod memory;
+mod priority_lru;
 mod reader;
 mod state;
 
 use self::file_type::FileType;
 use self::memory::MemoryCache;
+use self::priority_lru::DEFAULT_HIGH_PRIORITY_POOL_RATIO;
 use self::state::{BlockKey, CacheCoordinator, CacheReadToken};
 use crate::common::{CatalogOptions, Options};
 use crate::io::FileBlockCache;
@@ -75,7 +77,12 @@ impl LocalCache {
         })
     }
 
+    #[cfg(test)]
     pub(super) fn new(config: LocalCacheConfig) -> crate::Result<Self> {
+        Self::new_with_ratio(config, DEFAULT_HIGH_PRIORITY_POOL_RATIO)
+    }
+
+    fn new_with_ratio(config: LocalCacheConfig, ratio: f64) -> 
crate::Result<Self> {
         let file_size_capacity = config
             .max_size
             .map(|max_size| max_size / config.block_size)
@@ -83,12 +90,12 @@ impl LocalCache {
             .unwrap_or(DEFAULT_FILE_SIZE_CAPACITY)
             .clamp(1, DEFAULT_FILE_SIZE_CAPACITY);
         let (backend, coordinator) = if let Some(dir) = config.dir {
-            let disk = DiskCache::shared(dir.join(CACHE_DIRECTORY_NAME), 
config.max_size)?;
+            let disk = DiskCache::shared(dir.join(CACHE_DIRECTORY_NAME), 
config.max_size, ratio)?;
             let coordinator = disk.coordinator();
             (CacheBackend::Disk(disk), coordinator)
         } else {
             (
-                CacheBackend::Memory(MemoryCache::new(config.max_size)),
+                CacheBackend::Memory(MemoryCache::with_ratio(config.max_size, 
ratio)),
                 Arc::new(CacheCoordinator::default()),
             )
         };
@@ -234,10 +241,34 @@ pub(crate) fn create_local_cache_with_namespace(
     cache_options: &Options,
     namespace_options: &Options,
 ) -> crate::Result<Option<Arc<LocalCache>>> {
-    LocalCacheConfig::from_options_with_namespace(cache_options, 
namespace_options)?
-        .map(LocalCache::new)
-        .transpose()
-        .map(|cache| cache.map(Arc::new))
+    let Some(config) =
+        LocalCacheConfig::from_options_with_namespace(cache_options, 
namespace_options)?
+    else {
+        return Ok(None);
+    };
+    let ratio = parse_high_priority_ratio(cache_options)?;
+    LocalCache::new_with_ratio(config, ratio)
+        .map(Arc::new)
+        .map(Some)
+}
+
+fn parse_high_priority_ratio(options: &Options) -> crate::Result<f64> {
+    let key = CatalogOptions::LOCAL_CACHE_HIGH_PRIORITY_POOL_RATIO;
+    let Some(value) = options.get(key) else {
+        return Ok(DEFAULT_HIGH_PRIORITY_POOL_RATIO);
+    };
+    let ratio = value
+        .trim()
+        .parse::<f64>()
+        .map_err(|_| crate::Error::ConfigInvalid {
+            message: format!("{key} must be a number in [0, 1)"),
+        })?;
+    if !(0.0..1.0).contains(&ratio) {
+        return Err(crate::Error::ConfigInvalid {
+            message: format!("{key} must be in [0, 1)"),
+        });
+    }
+    Ok(ratio)
 }
 
 #[derive(Debug)]
@@ -369,6 +400,7 @@ mod tests {
         assert_eq!(config.dir, None);
         assert_eq!(config.max_size, None);
         assert_eq!(config.block_size, 1024 * 1024);
+        assert_eq!(parse_high_priority_ratio(&options).unwrap(), 0.25);
         assert_eq!(
             config.whitelist,
             HashSet::from([FileType::Meta, FileType::GlobalIndex])
@@ -467,6 +499,24 @@ mod tests {
         assert!(error.to_string().contains("local-cache.enabled"));
     }
 
+    #[test]
+    fn test_local_cache_high_priority_ratio_validation() {
+        let mut options = Options::new();
+        options.set(CatalogOptions::LOCAL_CACHE_ENABLED, "true");
+        for (value, expected) in [("0", 0.0), ("0.75", 0.75)] {
+            options.set(CatalogOptions::LOCAL_CACHE_HIGH_PRIORITY_POOL_RATIO, 
value);
+            assert_eq!(parse_high_priority_ratio(&options).unwrap(), expected);
+        }
+        for value in ["-0.1", "1", "NaN", "inf", "garbage"] {
+            options.set(CatalogOptions::LOCAL_CACHE_HIGH_PRIORITY_POOL_RATIO, 
value);
+            let error = create_local_cache(&options).unwrap_err();
+            assert!(matches!(error, crate::Error::ConfigInvalid { .. }));
+            assert!(error
+                .to_string()
+                
.contains(CatalogOptions::LOCAL_CACHE_HIGH_PRIORITY_POOL_RATIO));
+        }
+    }
+
     #[tokio::test]
     async fn test_local_cache_file_size_is_removed_with_path_invalidation() {
         let directory = tempfile::tempdir().unwrap();
diff --git a/crates/paimon/src/io/cache/priority_lru.rs 
b/crates/paimon/src/io/cache/priority_lru.rs
new file mode 100644
index 00000000..bf498428
--- /dev/null
+++ b/crates/paimon/src/io/cache/priority_lru.rs
@@ -0,0 +1,145 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements.  See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership.  The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License.  You may obtain a copy of the License at
+//
+//   http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied.  See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+use lru::LruCache;
+
+use super::file_type::FileType;
+use super::state::BlockKey;
+
+pub(super) const DEFAULT_HIGH_PRIORITY_POOL_RATIO: f64 = 0.25;
+
+pub(super) fn protected_limit(max_size: Option<u64>, ratio: f64) -> u64 {
+    if ratio == 0.0 {
+        0
+    } else {
+        max_size
+            .map(|size| (size as f64 * ratio) as u64)
+            .unwrap_or(u64::MAX)
+    }
+}
+
+#[derive(Debug)]
+struct Entry<V> {
+    value: V,
+    size: u64,
+}
+
+/// Newest high-priority blocks occupy a protected LRU; excess blocks compete
+/// with ordinary blocks in the regular LRU. Either class may use idle 
capacity.
+#[derive(Debug)]
+pub(super) struct PriorityLru<V> {
+    protected: LruCache<BlockKey, Entry<V>>,
+    regular: LruCache<BlockKey, Entry<V>>,
+    protected_size: u64,
+    protected_limit: u64,
+}
+
+impl<V> PriorityLru<V> {
+    pub(super) fn new(protected_limit: u64) -> Self {
+        Self {
+            protected: LruCache::unbounded(),
+            regular: LruCache::unbounded(),
+            protected_size: 0,
+            protected_limit,
+        }
+    }
+
+    pub(super) fn set_protected_limit(&mut self, limit: u64) {
+        self.protected_limit = limit;
+        self.demote_excess();
+    }
+
+    pub(super) fn contains_key(&self, key: &BlockKey) -> bool {
+        self.protected.contains(key) || self.regular.contains(key)
+    }
+
+    pub(super) fn keys(&self) -> impl Iterator<Item = &BlockKey> {
+        self.protected
+            .iter()
+            .map(|(key, _)| key)
+            .chain(self.regular.iter().map(|(key, _)| key))
+    }
+
+    pub(super) fn touch(&mut self, key: &BlockKey) -> Option<&V> {
+        if self.protected.contains(key) {
+            return self.protected.get(key).map(|entry| &entry.value);
+        }
+        if !self.regular.contains(key) {
+            return None;
+        }
+        if self.protected_limit > 0 && Self::high_priority(key) {
+            let entry = self.regular.pop(key)?;
+            self.protected_size = 
self.protected_size.saturating_add(entry.size);
+            self.protected.put(key.clone(), entry);
+            self.demote_excess();
+            if self.protected.contains(key) {
+                self.protected.get(key).map(|entry| &entry.value)
+            } else {
+                self.regular.get(key).map(|entry| &entry.value)
+            }
+        } else {
+            self.regular.get(key).map(|entry| &entry.value)
+        }
+    }
+
+    pub(super) fn insert(&mut self, key: BlockKey, value: V, size: u64) -> 
Option<(V, u64)> {
+        let previous = self.remove(&key);
+        let entry = Entry { value, size };
+        if self.protected_limit > 0 && Self::high_priority(&key) {
+            self.protected_size = self.protected_size.saturating_add(size);
+            self.protected.put(key, entry);
+            self.demote_excess();
+        } else {
+            self.regular.put(key, entry);
+        }
+        previous
+    }
+
+    pub(super) fn remove(&mut self, key: &BlockKey) -> Option<(V, u64)> {
+        if let Some(entry) = self.protected.pop(key) {
+            self.protected_size = 
self.protected_size.saturating_sub(entry.size);
+            return Some((entry.value, entry.size));
+        }
+        self.regular.pop(key).map(|entry| (entry.value, entry.size))
+    }
+
+    pub(super) fn pop_lru(&mut self) -> Option<(BlockKey, V, u64)> {
+        if let Some((key, entry)) = self.regular.pop_lru() {
+            return Some((key, entry.value, entry.size));
+        }
+        let (key, entry) = self.protected.pop_lru()?;
+        self.protected_size = self.protected_size.saturating_sub(entry.size);
+        Some((key, entry.value, entry.size))
+    }
+
+    fn demote_excess(&mut self) {
+        while self.protected_size > self.protected_limit {
+            let Some((key, entry)) = self.protected.pop_lru() else {
+                break;
+            };
+            self.protected_size = 
self.protected_size.saturating_sub(entry.size);
+            self.regular.put(key, entry);
+        }
+    }
+
+    fn high_priority(key: &BlockKey) -> bool {
+        matches!(
+            FileType::classify(&key.path),
+            FileType::Meta | FileType::GlobalIndex
+        )
+    }
+}
diff --git a/crates/paimon/src/io/cache/reader.rs 
b/crates/paimon/src/io/cache/reader.rs
index 240b163d..edb00a0a 100644
--- a/crates/paimon/src/io/cache/reader.rs
+++ b/crates/paimon/src/io/cache/reader.rs
@@ -297,6 +297,46 @@ mod tests {
         assert_eq!(delegate.reads.load(Ordering::SeqCst), 3);
     }
 
+    #[tokio::test]
+    async fn test_data_reads_do_not_evict_cached_snapshot_block() {
+        let delegate = Arc::new(CountingReader {
+            data: Bytes::from_static(b"data"),
+            reads: AtomicUsize::new(0),
+        });
+        let cache = Arc::new(
+            LocalCache::new(LocalCacheConfig {
+                dir: None,
+                namespace: "test".to_string(),
+                max_size: Some(16),
+                block_size: 4,
+                whitelist: std::collections::HashSet::from([
+                    FileType::Meta,
+                    FileType::StructuredData,
+                ]),
+            })
+            .unwrap(),
+        );
+        let snapshot = CachedFileReader::new(
+            delegate.clone(),
+            "s3://bucket/table/snapshot/snapshot-1",
+            4,
+            cache.clone(),
+        );
+        snapshot.read(0..4).await.unwrap();
+        for index in 1..=4 {
+            let reader = CachedFileReader::new(
+                delegate.clone(),
+                format!("s3://bucket/table/data/data-{index}.parquet"),
+                4,
+                cache.clone(),
+            );
+            reader.read(0..4).await.unwrap();
+        }
+        snapshot.read(0..4).await.unwrap();
+
+        assert_eq!(delegate.reads.load(Ordering::SeqCst), 5);
+    }
+
     #[derive(Debug)]
     struct SlowCountingReader {
         data: Bytes,
diff --git a/docs/src/getting-started.md b/docs/src/getting-started.md
index 49fca6a1..8c99f8c2 100644
--- a/docs/src/getting-started.md
+++ b/docs/src/getting-started.md
@@ -402,6 +402,7 @@ let catalog = CatalogFactory::create(options).await?;
 | `local-cache.enabled` | `false` | Enable catalog-scoped local block caching. 
|
 | `local-cache.dir` | none | Optional base directory. When set, Paimon uses a 
persistent disk cache in a private versioned child directory; otherwise it uses 
memory. |
 | `local-cache.max-size` | unlimited | Maximum cache size. Memory caches count 
payload bytes; disk caches count encoded bytes. Values accept byte units such 
as `512 MiB` or `20 GiB`. |
+| `local-cache.high-priority-pool-ratio` | `0.25` | Fraction of a finite cache 
budget protected for `meta` and `global-index` blocks. Use `0` for a single 
LRU. |
 | `local-cache.block-size` | `1 MiB` | Block size used for cached range reads. 
|
 | `local-cache.whitelist` | `meta,global-index` | Comma-separated eligible 
types: `meta`, `global-index`, `bucket-index`, `data`, `structured-data`, and 
`file-index`, or `*` for all of them. |
 
@@ -409,6 +410,9 @@ Use 
`local-cache.whitelist=meta,global-index,structured-data` to cache data
 blocks without dedicated BLOB, video, or vector-store files. `data` and `*`
 retain their broader behavior.
 
+The protected and regular LRUs share one total budget; either can use idle
+capacity. Catalogs sharing a disk cache within one process must use the same 
ratio.
+
 Each catalog owns its in-memory cache for the catalog's lifetime. Disk caches
 are reused after process restarts. Cache keys include a catalog-configuration
 fingerprint and the canonical storage object path, so catalogs can safely use

Reply via email to