From d2754cb1a25f58dd4b59b3dbad76f852c0f553e9 Mon Sep 17 00:00:00 2001 From: xiaohongbo Date: Sat, 26 Sep 2026 21:56:14 -0700 Subject: [PATCH 01/12] feat(core): support BLOB metadata caching --- crates/paimon/src/arrow/format/blob.rs | 8 +- crates/paimon/src/io/cache/disk.rs | 104 +++++++- crates/paimon/src/io/cache/file_type.rs | 4 +- crates/paimon/src/io/cache/memory.rs | 63 ++++- crates/paimon/src/io/cache/mod.rs | 34 ++- crates/paimon/src/io/cache/reader.rs | 251 ++++++++++++++++++++ crates/paimon/src/io/cache/state.rs | 23 ++ crates/paimon/src/io/file_io.rs | 37 ++- crates/paimon/src/table/data_file_reader.rs | 7 + docs/src/getting-started.md | 10 +- 10 files changed, 505 insertions(+), 36 deletions(-) diff --git a/crates/paimon/src/arrow/format/blob.rs b/crates/paimon/src/arrow/format/blob.rs index e15ef57b4..b29634522 100644 --- a/crates/paimon/src/arrow/format/blob.rs +++ b/crates/paimon/src/arrow/format/blob.rs @@ -1173,7 +1173,7 @@ async fn read_blob_array_range( ) -> crate::Result { let expected_length = range.end - range.start; let bytes = reader - .read(range.clone()) + .read_blob_metadata(range.clone()) .await .map_err(|e| Error::UnexpectedError { message: format!("Failed to read ARRAY {part} range {range:?}: {e}"), @@ -1613,7 +1613,7 @@ async fn read_blob_map_range( part: &str, ) -> crate::Result { let expected = range.end - range.start; - let bytes = reader.read(range.clone()).await?; + let bytes = reader.read_blob_metadata(range.clone()).await?; if bytes.len() as u64 != expected { return Err(Error::DataInvalid { message: format!( @@ -1670,7 +1670,7 @@ impl BlobFileIndex { } let footer = reader - .read(file_size - BLOB_FOOTER_SIZE..file_size) + .read_blob_metadata(file_size - BLOB_FOOTER_SIZE..file_size) .await .map_err(|e| Error::UnexpectedError { message: format!("Failed to read blob footer: {e}"), @@ -1712,7 +1712,7 @@ impl BlobFileIndex { let index_start = file_size - BLOB_FOOTER_SIZE - index_length; let data_region_end = index_start; let index_bytes = reader - .read(index_start..index_start + index_length) + .read_blob_metadata(index_start..index_start + index_length) .await .map_err(|e| Error::UnexpectedError { message: format!("Failed to read blob index bytes: {e}"), diff --git a/crates/paimon/src/io/cache/disk.rs b/crates/paimon/src/io/cache/disk.rs index 72cb84f9e..eae08060a 100644 --- a/crates/paimon/src/io/cache/disk.rs +++ b/crates/paimon/src/io/cache/disk.rs @@ -27,15 +27,15 @@ use tokio::io::AsyncWriteExt; use super::state::{BlockKey, CacheCoordinator, LogicalPath}; const CACHE_MAGIC: &[u8; 8] = b"PAIMONLC"; -const CACHE_FORMAT_VERSION: u8 = 2; -const FIXED_HEADER_LEN: usize = CACHE_MAGIC.len() + 1 + 4 + 4 + 8 + 8 + 8; +const CACHE_FORMAT_VERSION: u8 = 3; +const FIXED_HEADER_LEN: usize = CACHE_MAGIC.len() + 2 + 4 + 4 + 8 + 8 + 8; const CHECKSUM_LEN: usize = 4; const MAX_CACHE_KEY_HEADER_LEN: usize = 1024 * 1024; impl BlockKey { pub(super) fn cache_relative_path(&self) -> PathBuf { let mut digest = Sha256::new(); - digest.update([CACHE_FORMAT_VERSION]); + digest.update([CACHE_FORMAT_VERSION, u8::from(self.exact_range)]); digest.update((self.namespace.len() as u64).to_le_bytes()); digest.update(self.namespace.as_bytes()); digest.update((self.path.len() as u64).to_le_bytes()); @@ -199,7 +199,10 @@ impl DiskCache { pub(super) async fn put_block(&self, key: &BlockKey, payload: Bytes) { self.ensure_recovered().await; let encoded = encode_block(key, &payload); - let encoded_size = encoded.len() as u64; + let mut encoded_size = encoded.len() as u64; + if key.exact_range { + encoded_size = encoded_size.max(4096); + } if self .state .lock() @@ -258,6 +261,22 @@ impl DiskCache { let _ = tokio::fs::remove_file(&temporary).await; return; } + if key.exact_range { + if let Ok(metadata) = temporary_file.metadata().await { + encoded_size = range_disk_cost(&metadata); + } + if self + .state + .lock() + .unwrap_or_else(|error| error.into_inner()) + .max_size + .is_some_and(|limit| encoded_size > limit) + { + drop(temporary_file); + let _ = tokio::fs::remove_file(&temporary).await; + return; + } + } drop(temporary_file); if let Err(error) = tokio::fs::rename(&temporary, &path).await { log::debug!( @@ -398,8 +417,12 @@ fn prepare_cache_root(root: &Path) -> crate::Result { fn collect_evictions(state: &mut CacheState) -> Vec { let mut to_evict = Vec::new(); - if let Some(max_size) = state.max_size { - while state.current_size > max_size { + { + while state.entries.len() > super::MAX_CACHE_ENTRIES + || state + .max_size + .is_some_and(|limit| state.current_size > limit) + { let Some((eldest, size)) = state.entries.shift_remove_index(0) else { break; }; @@ -594,16 +617,36 @@ fn collect_cache_file( return; } }; + let cost = if key.exact_range { + range_disk_cost(&metadata) + } else { + metadata.len() + }; discovered.push(( metadata .modified() .unwrap_or(std::time::SystemTime::UNIX_EPOCH), path, key, - metadata.len(), + cost, )); } +fn range_disk_cost(metadata: &std::fs::Metadata) -> u64 { + #[cfg(unix)] + { + use std::os::unix::fs::MetadataExt; + metadata + .len() + .max(metadata.blocks().saturating_mul(512)) + .max(4096) + } + #[cfg(not(unix))] + { + metadata.len().saturating_add(4095) / 4096 * 4096 + } +} + fn read_block_key_header(path: &Path, encoded_len: u64) -> Result { let mut file = std::fs::File::open(path).map_err(|_| BlockDecodeError("cache block cannot be opened"))?; @@ -648,6 +691,7 @@ fn encode_block(key: &BlockKey, payload: &Bytes) -> Vec { ); encoded.extend_from_slice(CACHE_MAGIC); encoded.push(CACHE_FORMAT_VERSION); + encoded.push(u8::from(key.exact_range)); encoded.extend_from_slice(&(namespace.len() as u32).to_le_bytes()); encoded.extend_from_slice(&(path.len() as u32).to_le_bytes()); encoded.extend_from_slice(&key.block_size.to_le_bytes()); @@ -694,6 +738,7 @@ fn decode_block_any(encoded: &[u8]) -> Result<(BlockKey, Bytes), BlockDecodeErro #[derive(Clone, Copy, Debug)] struct BlockLayout { + exact_range: bool, namespace_len: usize, path_len: usize, block_size: u64, @@ -716,7 +761,12 @@ fn decode_block_layout( return Err(BlockDecodeError("cache block version is unsupported")); } - let mut offset = CACHE_MAGIC.len() + 1; + let exact_range = match fixed_header[CACHE_MAGIC.len() + 1] { + 0 => false, + 1 => true, + _ => return Err(BlockDecodeError("invalid cache range kind")), + }; + let mut offset = CACHE_MAGIC.len() + 2; let namespace_len = read_u32(fixed_header, &mut offset)? as usize; let path_len = read_u32(fixed_header, &mut offset)? as usize; let block_size = read_u64(fixed_header, &mut offset)?; @@ -745,6 +795,7 @@ fn decode_block_layout( } Ok(BlockLayout { + exact_range, namespace_len, path_len, block_size, @@ -771,10 +822,9 @@ fn decode_block_header( let path = std::str::from_utf8(&header[namespace_end..path_end]) .map_err(|_| BlockDecodeError("cache block path is not UTF-8"))?; - Ok(( - BlockKey::with_namespace(namespace, path, layout.block_size, layout.block_index), - layout, - )) + let mut key = BlockKey::with_namespace(namespace, path, layout.block_size, layout.block_index); + key.exact_range = layout.exact_range; + Ok((key, layout)) } fn read_u32(encoded: &[u8], offset: &mut usize) -> Result { @@ -804,6 +854,36 @@ mod tests { use super::*; use std::sync::Arc; + #[tokio::test] + async fn test_metadata_ranges_charge_disk_allocation_and_recover() { + let directory = tempfile::tempdir().unwrap(); + let cache = DiskCache::new(directory.path(), Some(8192)).unwrap(); + let manifest = BlockKey::new("manifest", 8, 0); + cache + .put_block(&manifest, Bytes::from_static(b"manifest")) + .await; + let mut key = BlockKey::new("data.blob", 16384, 0); + key.exact_range = true; + cache.put_block(&key, Bytes::from(vec![0; 16384])).await; + assert!(cache.get_block(&manifest).await.is_some()); + key.block_size = 1; + for offset in 0..100 { + key.block_index = offset; + cache.put_block(&key, Bytes::from_static(b"x")).await; + } + { + let state = cache.state.lock().unwrap(); + assert!(state.current_size <= 8192); + assert!(state.entries.len() <= 2); + } + drop(cache); + let cache = DiskCache::new(directory.path(), Some(8192)).unwrap(); + assert_eq!(cache.get_block(&key).await.unwrap(), b"x"[..]); + let state = cache.state.lock().unwrap(); + assert!(state.current_size <= 8192); + assert!(state.entries.len() <= 2); + } + #[test] fn test_disk_block_codec_round_trip() { let key = BlockKey::new("s3://bucket/table/snapshot/snapshot-1", 1024, 3); diff --git a/crates/paimon/src/io/cache/file_type.rs b/crates/paimon/src/io/cache/file_type.rs index 2db0ae5a7..91d2e2f97 100644 --- a/crates/paimon/src/io/cache/file_type.rs +++ b/crates/paimon/src/io/cache/file_type.rs @@ -24,6 +24,7 @@ pub(super) enum FileType { BucketIndex, GlobalIndex, FileIndex, + BlobMeta, } impl FileType { @@ -76,11 +77,12 @@ impl FileType { "bucket-index" => Some(Self::BucketIndex), "data" => Some(Self::Data), "file-index" => Some(Self::FileIndex), + "blob-meta" => Some(Self::BlobMeta), "" => None, unknown => { log::warn!( "Unknown local-cache.whitelist value '{}'; supported values are \ - meta, global-index, bucket-index, data, file-index", + meta, global-index, bucket-index, data, file-index, blob-meta", unknown ); None diff --git a/crates/paimon/src/io/cache/memory.rs b/crates/paimon/src/io/cache/memory.rs index c50cd91af..f807f1678 100644 --- a/crates/paimon/src/io/cache/memory.rs +++ b/crates/paimon/src/io/cache/memory.rs @@ -50,7 +50,7 @@ impl MemoryCache { } pub(super) fn put_block(&self, key: &BlockKey, payload: Bytes) { - let payload_size = payload.len() as u64; + let payload_size = key.memory_cost(payload.len()); let mut state = self.state.lock().unwrap_or_else(|error| error.into_inner()); if state .max_size @@ -59,24 +59,31 @@ 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); + state.current_size = state + .current_size + .saturating_sub(key.memory_cost(previous.len())); } state.current_size = state.current_size.saturating_add(payload_size); - while state - .max_size - .is_some_and(|max_size| state.current_size > max_size) + while state.entries.len() > super::MAX_CACHE_ENTRIES + || state + .max_size + .is_some_and(|max_size| state.current_size > max_size) { - let Some((_, payload)) = state.entries.pop_lru() else { + let Some((key, payload)) = 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(key.memory_cost(payload.len())); } } 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); + state.current_size = state + .current_size + .saturating_sub(key.memory_cost(payload.len())); } } @@ -100,7 +107,9 @@ impl MemoryCache { .collect::>(); for key in keys { if let Some(payload) = state.entries.pop(&key) { - state.current_size = state.current_size.saturating_sub(payload.len() as u64); + state.current_size = state + .current_size + .saturating_sub(key.memory_cost(payload.len())); } } } @@ -110,6 +119,42 @@ impl MemoryCache { mod tests { use super::*; + #[test] + fn test_metadata_range_entry_count_is_bounded_without_byte_limit() { + let cache = MemoryCache::new(None); + let mut key = BlockKey::new("data.blob", 1, 0); + key.exact_range = true; + for offset in 0..super::super::MAX_CACHE_ENTRIES + 1 { + key.block_index = offset as u64; + cache.put_block(&key, Bytes::from_static(b"x")); + } + assert_eq!( + cache.state.lock().unwrap().entries.len(), + super::super::MAX_CACHE_ENTRIES + ); + key.block_index = 0; + assert!(cache.get_block(&key).is_none()); + } + + #[test] + fn test_metadata_ranges_have_bounded_cost_and_skip_oversized_entries() { + let cache = MemoryCache::new(Some(4096)); + let manifest = BlockKey::new("manifest", 8, 0); + cache.put_block(&manifest, Bytes::from_static(b"manifest")); + let mut key = BlockKey::new("data.blob", 8192, 0); + key.exact_range = true; + cache.put_block(&key, Bytes::from(vec![0; 8192])); + assert!(cache.get_block(&manifest).is_some()); + key.block_size = 1; + for offset in 0..1000 { + key.block_index = offset; + cache.put_block(&key, Bytes::from_static(b"x")); + } + let state = cache.state.lock().unwrap(); + assert!(state.current_size <= 4096); + assert!(state.entries.len() <= 8); + } + #[test] fn test_memory_cache_refreshes_lru_and_evicts_by_payload_bytes() { let cache = MemoryCache::new(Some(8)); diff --git a/crates/paimon/src/io/cache/mod.rs b/crates/paimon/src/io/cache/mod.rs index 3f661a47c..145d7107d 100644 --- a/crates/paimon/src/io/cache/mod.rs +++ b/crates/paimon/src/io/cache/mod.rs @@ -34,7 +34,8 @@ use std::sync::Arc; use disk::DiskCache; pub(super) use reader::CachedFileReader; -const CACHE_DIRECTORY_NAME: &str = "paimon-local-cache-v2"; +const CACHE_DIRECTORY_NAME: &str = "paimon-local-cache-v3"; +const MAX_CACHE_ENTRIES: usize = 65_536; const DEFAULT_FILE_SIZE_CAPACITY: usize = 65_536; #[derive(Debug)] @@ -111,7 +112,22 @@ impl LocalCache { } pub(super) fn is_cacheable(&self, path: &str) -> bool { - !FileType::is_mutable(path) && self.whitelist.contains(&FileType::classify(path)) + !FileType::is_mutable(path) && (self.cache_blocks(path) || self.cache_blob_metadata(path)) + } + + pub(super) fn cache_blocks(&self, path: &str) -> bool { + self.whitelist.contains(&FileType::classify(path)) + } + + pub(super) fn cache_blob_metadata(&self, path: &str) -> bool { + path.ends_with(".blob") && self.whitelist.contains(&FileType::BlobMeta) + } + + fn range_key(&self, path: &str, range: &std::ops::Range) -> BlockKey { + let mut key = + BlockKey::with_namespace(&self.namespace, path, range.end - range.start, range.start); + key.exact_range = true; + key } async fn get_block( @@ -129,7 +145,7 @@ impl LocalCache { CacheBackend::Memory(memory) => memory.get_block(key), CacheBackend::Disk(disk) => disk.get_block(key).await, CacheBackend::External(cache) => { - let start = key.block_index.checked_mul(key.block_size)?; + let start = key.offset()?; let length = u64::try_from(expected_len).ok()?; let end = start.checked_add(length)?; cache.get(&key.path, start..end).await @@ -152,7 +168,7 @@ impl LocalCache { CacheBackend::Memory(memory) => memory.put_block(key, payload), CacheBackend::Disk(disk) => disk.put_block(key, payload).await, CacheBackend::External(cache) => { - if let Some(offset) = key.block_index.checked_mul(key.block_size) { + if let Some(offset) = key.offset() { cache.put(&key.path, offset, payload).await; } } @@ -305,7 +321,7 @@ impl LocalCacheConfig { let whitelist = options .get(CatalogOptions::LOCAL_CACHE_WHITELIST) .map(String::as_str) - .unwrap_or("meta,global-index"); + .unwrap_or("meta,global-index,blob-meta"); Ok(Some(Self { dir, @@ -372,7 +388,7 @@ mod tests { assert_eq!(config.block_size, 1024 * 1024); assert_eq!( config.whitelist, - HashSet::from([FileType::Meta, FileType::GlobalIndex]) + HashSet::from([FileType::Meta, FileType::GlobalIndex, FileType::BlobMeta]) ); } @@ -391,7 +407,11 @@ mod tests { assert_eq!(config.block_size, 1024 * 1024); assert_eq!( config.whitelist, - std::collections::HashSet::from([FileType::Meta, FileType::GlobalIndex]) + std::collections::HashSet::from([ + FileType::Meta, + FileType::GlobalIndex, + FileType::BlobMeta + ]) ); } diff --git a/crates/paimon/src/io/cache/reader.rs b/crates/paimon/src/io/cache/reader.rs index 240b163d1..d2008ab54 100644 --- a/crates/paimon/src/io/cache/reader.rs +++ b/crates/paimon/src/io/cache/reader.rs @@ -208,7 +208,59 @@ impl CachedFileReader { #[async_trait::async_trait] impl FileRead for CachedFileReader { + async fn read_blob_metadata(&self, range: Range) -> crate::Result { + if !self.cache.cache_blob_metadata(&self.path) || self.cache.cache_blocks(&self.path) { + return self.read(range).await; + } + if range.start >= range.end || range.end > self.file_size { + return self.delegate.read(range).await; + } + let key = self.cache.range_key(&self.path, &range); + let expected_len = + usize::try_from(range.end - range.start).map_err(|_| crate::Error::DataInvalid { + message: "BLOB metadata range is too large".to_string(), + source: None, + })?; + if let Some(payload) = self + .cache + .get_block(&key, expected_len, &self.read_token) + .await + { + if payload.len() == expected_len { + return Ok(payload); + } + } + let load_lock = self.cache.block_load_lock(&key).await; + let guard = load_lock.lock().await; + let result = async { + if let Some(payload) = self + .cache + .get_block(&key, expected_len, &self.read_token) + .await + { + if payload.len() == expected_len { + return Ok(payload); + } + } + let payload = self.delegate.read(range).await?; + if payload.len() == expected_len { + // Own only this range, even if the delegate returns a slice of a larger buffer. + self.cache + .put_block(&key, Bytes::copy_from_slice(&payload), &self.read_token) + .await; + } + Ok(payload) + } + .await; + drop(guard); + self.cache.release_block_load_lock(&key, &load_lock).await; + result + } + async fn read(&self, range: Range) -> crate::Result { + if !self.cache.cache_blocks(&self.path) { + return self.delegate.read(range).await; + } if range.start > range.end || range.end > self.file_size { return self.delegate.read(range).await; } @@ -248,6 +300,205 @@ mod tests { use crate::common::{CatalogOptions, Options}; use crate::io::cache::create_local_cache; + #[tokio::test] + async fn test_blob_metadata_exact_ranges_and_body_bypass() { + for disk in [false, true] { + let directory = tempfile::tempdir().unwrap(); + let config = || LocalCacheConfig { + dir: disk.then(|| directory.path().to_path_buf()), + namespace: "blob-test".into(), + max_size: Some(64 * 1024), + block_size: 1024, + whitelist: std::collections::HashSet::from([FileType::BlobMeta]), + }; + let cache = Arc::new(LocalCache::new(config()).unwrap()); + let delegate = Arc::new(CountingReader { + data: Bytes::from_static(b"abcdefgh"), + reads: AtomicUsize::new(0), + }); + let reader = CachedFileReader::new(delegate.clone(), "data.blob", 8, cache.clone()); + assert_eq!(reader.read_blob_metadata(1..3).await.unwrap(), b"bc"[..]); + assert_eq!(reader.read_blob_metadata(1..4).await.unwrap(), b"bcd"[..]); + assert_eq!(reader.read_blob_metadata(1..3).await.unwrap(), b"bc"[..]); + assert_eq!(delegate.reads.load(Ordering::SeqCst), 2); + // Values never enter the metadata cache. + reader.read(4..8).await.unwrap(); + reader.read(4..8).await.unwrap(); + assert_eq!(delegate.reads.load(Ordering::SeqCst), 4); + drop(reader); + drop(cache); + if disk { + let cache = Arc::new(LocalCache::new(config()).unwrap()); + let reader = CachedFileReader::new(delegate.clone(), "data.blob", 8, cache.clone()); + assert_eq!(reader.read_blob_metadata(1..3).await.unwrap(), b"bc"[..]); + assert_eq!(delegate.reads.load(Ordering::SeqCst), 4); + cache.invalidate_path("data.blob").await; + let reader = CachedFileReader::new(delegate.clone(), "data.blob", 8, cache); + reader.read_blob_metadata(1..3).await.unwrap(); + assert_eq!(delegate.reads.load(Ordering::SeqCst), 5); + } + } + } + + #[tokio::test] + async fn test_blob_metadata_cold_reads_are_coalesced() { + let delegate = Arc::new(SlowCountingReader { + data: Bytes::from_static(b"abcdefgh"), + reads: AtomicUsize::new(0), + }); + let cache = Arc::new( + LocalCache::new(LocalCacheConfig { + dir: None, + namespace: "test".into(), + max_size: Some(65536), + block_size: 1024, + whitelist: std::collections::HashSet::from([FileType::BlobMeta]), + }) + .unwrap(), + ); + let readers: Vec<_> = (0..16) + .map(|_| CachedFileReader::new(delegate.clone(), "data.blob", 8, cache.clone())) + .collect(); + let results = + futures::future::join_all(readers.iter().map(|reader| reader.read_blob_metadata(1..3))) + .await; + assert!(results + .into_iter() + .all(|result| result.unwrap() == b"bc"[..])); + assert_eq!(delegate.reads.load(Ordering::SeqCst), 1); + } + + #[allow(dead_code)] + mod blob_utils { + include!(concat!(env!("CARGO_MANIFEST_DIR"), "/blob_test_utils.rs")); + } + + #[tokio::test] + async fn test_blob_descriptor_scans_cache_metadata_not_values() { + use crate::arrow::format::{blob::BlobFormatReader, FormatFileReader}; + use crate::spec::{ArrayType, BlobType, DataField, DataType, MapType, VarCharType}; + use futures::TryStreamExt; + use std::sync::Mutex; + + struct RecordingReader { + data: Bytes, + ranges: Mutex>>, + } + #[async_trait::async_trait] + impl FileRead for RecordingReader { + async fn read(&self, range: Range) -> crate::Result { + self.ranges.lock().unwrap().push(range.clone()); + Ok(self.data.slice(range.start as usize..range.end as usize)) + } + } + let blob_type = DataType::Blob(BlobType::new()); + let mut map_payload = Vec::new(); + map_payload.extend_from_slice(&0x4D424342_i32.to_le_bytes()); + map_payload.push(1); + map_payload.extend_from_slice(&1_i32.to_le_bytes()); + map_payload.extend_from_slice(b"keyhello"); + let key_index = blob_utils::encode_delta_varints(&[3]); + let value_index = blob_utils::encode_delta_varints(&[5]); + map_payload.extend_from_slice(&key_index); + map_payload.extend_from_slice(&value_index); + map_payload.extend_from_slice(&(key_index.len() as i32).to_le_bytes()); + map_payload.extend_from_slice(&(value_index.len() as i32).to_le_bytes()); + let cases = [ + ( + include_bytes!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/testdata/blob/blob-basic.blob" + )) + .to_vec(), + blob_type.clone(), + ), + ( + include_bytes!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/testdata/blob/blob-array.blob" + )) + .to_vec(), + DataType::Array(ArrayType::new(blob_type.clone())), + ), + ( + blob_utils::build_blob_file_bytes(&[Some(&map_payload)]), + DataType::Map(MapType::new( + DataType::VarChar(VarCharType::new(100).unwrap()), + blob_type, + )), + ), + ]; + for disk in [false, true] { + for (data, data_type) in &cases { + let directory = tempfile::tempdir().unwrap(); + let cache = Arc::new( + LocalCache::new(LocalCacheConfig { + dir: disk.then(|| directory.path().to_path_buf()), + namespace: "scan".into(), + max_size: Some(65536), + block_size: 1024, + whitelist: std::collections::HashSet::from([FileType::BlobMeta]), + }) + .unwrap(), + ); + let delegate = Arc::new(RecordingReader { + data: Bytes::copy_from_slice(data), + ranges: Mutex::new(Vec::new()), + }); + let fields = [DataField::new(0, "value".into(), data_type.clone())]; + let scan = |descriptor| { + let reader = CachedFileReader::new( + delegate.clone(), + "data.blob", + data.len() as u64, + cache.clone(), + ); + let fields = fields.clone(); + async move { + BlobFormatReader::new("data.blob".into(), descriptor) + .read_batch_stream( + Box::new(reader), + data.len() as u64, + &fields, + None, + Some(1024), + None, + ) + .await + .unwrap() + .try_collect::>() + .await + .unwrap() + } + }; + let first = scan(true).await; + let reads = delegate.ranges.lock().unwrap().clone(); + assert!(!reads.is_empty()); + for needle in [b"hello", b"world"] { + for (offset, _) in data + .windows(needle.len()) + .enumerate() + .filter(|(_, bytes)| *bytes == needle) + { + assert!(reads.iter().all(|range| range.end <= offset as u64 + || range.start >= (offset + needle.len()) as u64)); + } + } + assert_eq!(scan(true).await, first); + assert_eq!(delegate.ranges.lock().unwrap().len(), reads.len()); + let materialized = scan(false).await; + assert_eq!( + materialized + .iter() + .map(|batch| batch.num_rows()) + .sum::(), + first.iter().map(|batch| batch.num_rows()).sum::() + ); + assert!(delegate.ranges.lock().unwrap().len() > reads.len()); + } + } + } + #[derive(Debug)] struct CountingReader { data: Bytes, diff --git a/crates/paimon/src/io/cache/state.rs b/crates/paimon/src/io/cache/state.rs index 7625186b2..354714999 100644 --- a/crates/paimon/src/io/cache/state.rs +++ b/crates/paimon/src/io/cache/state.rs @@ -23,8 +23,10 @@ use std::sync::{Arc, Mutex, Weak}; pub(super) struct BlockKey { pub(super) namespace: String, pub(super) path: String, + // Exact ranges use length/start; aligned blocks use block size/index. pub(super) block_size: u64, pub(super) block_index: u64, + pub(super) exact_range: bool, } impl BlockKey { @@ -44,6 +46,27 @@ impl BlockKey { path: path.into(), block_size, block_index, + exact_range: false, + } + } + + pub(super) fn offset(&self) -> Option { + if self.exact_range { + Some(self.block_index) + } else { + self.block_index.checked_mul(self.block_size) + } + } + + pub(super) fn memory_cost(&self, payload_len: usize) -> u64 { + let payload_len = payload_len as u64; + if self.exact_range { + payload_len + .saturating_add((self.namespace.len() + self.path.len()) as u64) + .saturating_add(256) + .max(512) + } else { + payload_len } } diff --git a/crates/paimon/src/io/file_io.rs b/crates/paimon/src/io/file_io.rs index bc8e2fb56..57befdeec 100644 --- a/crates/paimon/src/io/file_io.rs +++ b/crates/paimon/src/io/file_io.rs @@ -107,7 +107,7 @@ impl FileIO { /// `block_size` controls the aligned ranges presented to the cache. /// `whitelist` uses the same comma-separated values as /// `local-cache.whitelist`: `meta`, `global-index`, `bucket-index`, `data`, - /// and `file-index`. + /// `file-index`, and `blob-meta` (exact BLOB metadata ranges). pub fn with_file_block_cache( mut self, cache: Arc, @@ -688,6 +688,11 @@ impl FileIOBuilder { #[async_trait::async_trait] pub trait FileRead: Send + Sync + Unpin + 'static { + /// Read BLOB metadata, allowing caches to retain only the requested range. + async fn read_blob_metadata(&self, range: Range) -> crate::Result { + self.read(range).await + } + async fn read(&self, range: Range) -> crate::Result; } @@ -705,6 +710,13 @@ enum InputFileReader { #[async_trait::async_trait] impl FileRead for InputFileReader { + async fn read_blob_metadata(&self, range: Range) -> crate::Result { + match self { + Self::Direct(reader) => reader.read_blob_metadata(range).await, + Self::Cached(reader) => reader.read_blob_metadata(range).await, + } + } + async fn read(&self, range: Range) -> crate::Result { match self { Self::Direct(reader) => FileRead::read(reader, range).await, @@ -879,6 +891,9 @@ impl InputFile { let Some(cache) = &self.cache else { return Ok(op.read(&relative_path).await?.to_bytes()); }; + if !cache.cache_blocks(&self.path) { + return Ok(op.read(&relative_path).await?.to_bytes()); + } let read_token = cache.read_token(&cache_path); let size = if let Some(size) = cache.file_size(&cache_path, &read_token).await { size @@ -1625,6 +1640,26 @@ mod input_output_test { .unwrap() } + #[tokio::test] + async fn test_blob_metadata_default_file_io_cache_does_not_cache_body() { + let source = tempfile::tempdir().unwrap(); + let cache_dir = tempfile::tempdir().unwrap(); + let path = source.path().join("data.blob"); + std::fs::write(&path, b"metabody").unwrap(); + let location = path.to_str().unwrap(); + let file_io = setup_cached_fs_file_io(cache_dir.path()); + let input = file_io.new_input(location).unwrap(); + let reader = input.reader().await.unwrap(); + assert_eq!(reader.read_blob_metadata(0..4).await.unwrap(), b"meta"[..]); + assert_eq!(reader.read(4..8).await.unwrap(), b"body"[..]); + drop(reader); + std::fs::remove_file(&path).unwrap(); + let reader = input.reader().await.unwrap(); + assert_eq!(reader.read_blob_metadata(0..4).await.unwrap(), b"meta"[..]); + assert!(reader.read(4..8).await.is_err()); + assert!(input.read().await.is_err()); + } + 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(); diff --git a/crates/paimon/src/table/data_file_reader.rs b/crates/paimon/src/table/data_file_reader.rs index 16d6bfa15..58eb32122 100644 --- a/crates/paimon/src/table/data_file_reader.rs +++ b/crates/paimon/src/table/data_file_reader.rs @@ -101,6 +101,13 @@ struct TimedFileRead { #[async_trait::async_trait] impl FileRead for TimedFileRead { + async fn read_blob_metadata(&self, range: Range) -> crate::Result { + let start = Instant::now(); + let result = self.inner.read_blob_metadata(range).await; + self.timing.add_file_read(start.elapsed()); + result + } + async fn read(&self, range: Range) -> crate::Result { let start = Instant::now(); let result = self.inner.read(range).await; diff --git a/docs/src/getting-started.md b/docs/src/getting-started.md index de526dff4..cb917334b 100644 --- a/docs/src/getting-started.md +++ b/docs/src/getting-started.md @@ -313,7 +313,7 @@ options.set(CatalogOptions::LOCAL_CACHE_MAX_SIZE, "20 GiB"); options.set(CatalogOptions::LOCAL_CACHE_BLOCK_SIZE, "1 MiB"); options.set( CatalogOptions::LOCAL_CACHE_WHITELIST, - "meta,global-index", + "meta,global-index,blob-meta", ); let catalog = CatalogFactory::create(options).await?; ``` @@ -324,7 +324,13 @@ let catalog = CatalogFactory::create(options).await?; | `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.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`, and `file-index`. | +| `local-cache.whitelist` | `meta,global-index,blob-meta` | Cache categories: `meta`, `global-index`, `bucket-index`, `data`, `file-index`, and `blob-meta`. | + +`blob-meta` caches exact BLOB footer/index and ARRAY/MAP metadata ranges, including MAP keys, +without caching value bodies. Metadata ranges share the cache budget, with estimated memory +overhead (at least 512 bytes) or disk allocation (at least 4 KiB) charged per entry. Built-in +caches hold at most 65,536 entries and skip entries larger than the budget. `data` still enables +ordinary block caching. This caches bytes used to build descriptors, not decoded descriptors. 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 From a012d78fa08c5510b2138425def7d965a26e1e67 Mon Sep 17 00:00:00 2001 From: xiaohongbo Date: Sat, 26 Sep 2026 23:24:56 -0700 Subject: [PATCH 02/12] fix(core): bound BLOB metadata cache allocations --- crates/paimon/src/io/cache/disk.rs | 123 +++++++++++++++++++++------ crates/paimon/src/io/cache/memory.rs | 15 +++- crates/paimon/src/io/cache/mod.rs | 28 ++++++ crates/paimon/src/io/cache/reader.rs | 64 +++++++++++++- crates/paimon/src/io/cache/state.rs | 17 ++-- 5 files changed, 211 insertions(+), 36 deletions(-) diff --git a/crates/paimon/src/io/cache/disk.rs b/crates/paimon/src/io/cache/disk.rs index eae08060a..6cbc0e9ec 100644 --- a/crates/paimon/src/io/cache/disk.rs +++ b/crates/paimon/src/io/cache/disk.rs @@ -161,6 +161,17 @@ impl DiskCache { self.coordinator.clone() } + pub(super) fn can_store(&self, key: &BlockKey, payload_len: usize) -> bool { + let Some(encoded_size) = encoded_block_disk_cost(key, payload_len) else { + return false; + }; + self.state + .lock() + .unwrap_or_else(|error| error.into_inner()) + .max_size + .is_none_or(|max_size| encoded_size <= max_size) + } + pub(super) async fn get_block(&self, key: &BlockKey) -> Option { self.ensure_recovered().await; if !self.is_active(key) { @@ -181,7 +192,7 @@ impl DiskCache { return None; } }; - match decode_block(key, &encoded) { + match decode_owned_block(key, encoded) { Ok(payload) if self.touch_entry(key) => Some(payload), Ok(_) => None, Err(error) => { @@ -198,10 +209,11 @@ impl DiskCache { pub(super) async fn put_block(&self, key: &BlockKey, payload: Bytes) { self.ensure_recovered().await; - let encoded = encode_block(key, &payload); - let mut encoded_size = encoded.len() as u64; - if key.exact_range { - encoded_size = encoded_size.max(4096); + let Some(mut encoded_size) = encoded_block_disk_cost(key, payload.len()) else { + return; + }; + if key.exact_range && payload.len() > super::MAX_EXACT_RANGE_CACHE_ENTRY_SIZE { + return; } if self .state @@ -212,6 +224,9 @@ impl DiskCache { { return; } + let Some(header) = encode_block_header(key, payload.len()) else { + return; + }; let path = self.root.join(key.cache_relative_path()); let Some(parent) = path.parent() else { @@ -246,8 +261,14 @@ impl DiskCache { return; } }; + let mut checksum = crc32fast::Hasher::new(); + checksum.update(&header); + checksum.update(&payload); + let checksum = checksum.finalize().to_le_bytes(); let write_result = async { - temporary_file.write_all(&encoded).await?; + temporary_file.write_all(&header).await?; + temporary_file.write_all(&payload).await?; + temporary_file.write_all(&checksum).await?; // Tokio may return from write_all before the blocking write completes. temporary_file.flush().await } @@ -683,38 +704,81 @@ fn is_cache_temporary_name(name: &str, shard: &str) -> bool { is_cache_block_name(digest, shard) && uuid::Uuid::parse_str(suffix).is_ok() } -fn encode_block(key: &BlockKey, payload: &Bytes) -> Vec { +fn encoded_block_len(key: &BlockKey, payload_len: usize) -> Option { + let key_len = key.namespace.len().checked_add(key.path.len())?; + if key_len > MAX_CACHE_KEY_HEADER_LEN + || u32::try_from(key.namespace.len()).is_err() + || u32::try_from(key.path.len()).is_err() + { + return None; + } + FIXED_HEADER_LEN + .checked_add(key_len)? + .checked_add(payload_len)? + .checked_add(CHECKSUM_LEN) +} + +fn encoded_block_disk_cost(key: &BlockKey, payload_len: usize) -> Option { + let encoded_len = u64::try_from(encoded_block_len(key, payload_len)?).ok()?; + if key.exact_range { + encoded_len + .checked_add(4095) + .map(|length| length / 4096 * 4096) + } else { + Some(encoded_len) + } +} + +fn encode_block_header(key: &BlockKey, payload_len: usize) -> Option> { let namespace = key.namespace.as_bytes(); let path = key.path.as_bytes(); - let mut encoded = Vec::with_capacity( - FIXED_HEADER_LEN + namespace.len() + path.len() + payload.len() + CHECKSUM_LEN, - ); - encoded.extend_from_slice(CACHE_MAGIC); - encoded.push(CACHE_FORMAT_VERSION); - encoded.push(u8::from(key.exact_range)); - encoded.extend_from_slice(&(namespace.len() as u32).to_le_bytes()); - encoded.extend_from_slice(&(path.len() as u32).to_le_bytes()); - encoded.extend_from_slice(&key.block_size.to_le_bytes()); - encoded.extend_from_slice(&key.block_index.to_le_bytes()); - encoded.extend_from_slice(&(payload.len() as u64).to_le_bytes()); - encoded.extend_from_slice(namespace); - encoded.extend_from_slice(path); + let encoded_len = encoded_block_len(key, payload_len)?; + let header_len = FIXED_HEADER_LEN + .checked_add(namespace.len())? + .checked_add(path.len())?; + let mut header = Vec::with_capacity(header_len); + header.extend_from_slice(CACHE_MAGIC); + header.push(CACHE_FORMAT_VERSION); + header.push(u8::from(key.exact_range)); + header.extend_from_slice(&u32::try_from(namespace.len()).ok()?.to_le_bytes()); + header.extend_from_slice(&u32::try_from(path.len()).ok()?.to_le_bytes()); + header.extend_from_slice(&key.block_size.to_le_bytes()); + header.extend_from_slice(&key.block_index.to_le_bytes()); + header.extend_from_slice(&u64::try_from(payload_len).ok()?.to_le_bytes()); + header.extend_from_slice(namespace); + header.extend_from_slice(path); + debug_assert_eq!(encoded_len, header.len() + payload_len + CHECKSUM_LEN); + Some(header) +} + +#[cfg(test)] +fn encode_block(key: &BlockKey, payload: &Bytes) -> Vec { + let mut encoded = encode_block_header(key, payload.len()).unwrap(); encoded.extend_from_slice(payload); let checksum = crc32fast::hash(&encoded); encoded.extend_from_slice(&checksum.to_le_bytes()); encoded } +#[cfg(test)] fn decode_block(key: &BlockKey, encoded: &[u8]) -> Result { - let (decoded_key, payload) = decode_block_any(encoded)?; + decode_bytes_block(key, Bytes::copy_from_slice(encoded)) +} + +fn decode_owned_block(key: &BlockKey, encoded: Vec) -> Result { + decode_bytes_block(key, Bytes::from(encoded)) +} + +fn decode_bytes_block(key: &BlockKey, encoded: Bytes) -> Result { + let (decoded_key, payload) = decode_bytes_block_any(encoded)?; if &decoded_key != key { return Err(BlockDecodeError("cache block key does not match")); } Ok(payload) } -fn decode_block_any(encoded: &[u8]) -> Result<(BlockKey, Bytes), BlockDecodeError> { - let (key, layout) = decode_block_header(encoded, encoded.len() as u64)?; +fn decode_bytes_block_any(encoded: Bytes) -> Result<(BlockKey, Bytes), BlockDecodeError> { + let (key, layout) = decode_block_header(&encoded, encoded.len() as u64)?; let payload_len = usize::try_from(layout.payload_len) .map_err(|_| BlockDecodeError("cache block payload is too large"))?; let payload_end = layout @@ -730,10 +794,7 @@ fn decode_block_any(encoded: &[u8]) -> Result<(BlockKey, Bytes), BlockDecodeErro return Err(BlockDecodeError("cache block checksum does not match")); } - Ok(( - key, - Bytes::copy_from_slice(&encoded[layout.header_len..payload_end]), - )) + Ok((key, encoded.slice(layout.header_len..payload_end))) } #[derive(Clone, Copy, Debug)] @@ -895,6 +956,14 @@ mod tests { assert_eq!(decoded, payload); } + #[test] + fn test_disk_block_size_preflight_rejects_overflow() { + let mut key = BlockKey::new("data.blob", 1, 0); + key.exact_range = true; + + assert_eq!(encoded_block_disk_cost(&key, usize::MAX), None); + } + #[test] fn test_disk_block_codec_rejects_crc_corruption() { let key = BlockKey::new("s3://bucket/table/snapshot/snapshot-1", 1024, 3); diff --git a/crates/paimon/src/io/cache/memory.rs b/crates/paimon/src/io/cache/memory.rs index f807f1678..c8466dcde 100644 --- a/crates/paimon/src/io/cache/memory.rs +++ b/crates/paimon/src/io/cache/memory.rs @@ -49,8 +49,21 @@ impl MemoryCache { state.entries.get(key).cloned() } + pub(super) fn can_store(&self, key: &BlockKey, payload_len: usize) -> bool { + let Some(payload_size) = key.checked_memory_cost(payload_len) else { + return false; + }; + self.state + .lock() + .unwrap_or_else(|error| error.into_inner()) + .max_size + .is_none_or(|max_size| payload_size <= max_size) + } + pub(super) fn put_block(&self, key: &BlockKey, payload: Bytes) { - let payload_size = key.memory_cost(payload.len()); + let Some(payload_size) = key.checked_memory_cost(payload.len()) else { + return; + }; let mut state = self.state.lock().unwrap_or_else(|error| error.into_inner()); if state .max_size diff --git a/crates/paimon/src/io/cache/mod.rs b/crates/paimon/src/io/cache/mod.rs index 145d7107d..9976dc08a 100644 --- a/crates/paimon/src/io/cache/mod.rs +++ b/crates/paimon/src/io/cache/mod.rs @@ -36,6 +36,7 @@ pub(super) use reader::CachedFileReader; const CACHE_DIRECTORY_NAME: &str = "paimon-local-cache-v3"; const MAX_CACHE_ENTRIES: usize = 65_536; +const MAX_EXACT_RANGE_CACHE_ENTRY_SIZE: usize = 64 * 1024 * 1024; const DEFAULT_FILE_SIZE_CAPACITY: usize = 65_536; #[derive(Debug)] @@ -130,6 +131,17 @@ impl LocalCache { key } + fn can_cache_exact_range(&self, key: &BlockKey, payload_len: usize) -> bool { + if !key.exact_range || payload_len > MAX_EXACT_RANGE_CACHE_ENTRY_SIZE { + return false; + } + match &self.backend { + CacheBackend::Memory(memory) => memory.can_store(key, payload_len), + CacheBackend::Disk(disk) => disk.can_store(key, payload_len), + CacheBackend::External(_) => true, + } + } + async fn get_block( &self, key: &BlockKey, @@ -376,6 +388,22 @@ mod tests { .is_none()); } + #[test] + fn test_exact_range_cache_entry_has_a_hard_limit() { + let cache = LocalCache::new(LocalCacheConfig { + dir: None, + namespace: "test".into(), + max_size: None, + block_size: 1024, + whitelist: HashSet::from([FileType::BlobMeta]), + }) + .unwrap(); + let key = cache.range_key("data.blob", &(0..MAX_EXACT_RANGE_CACHE_ENTRY_SIZE as u64)); + + assert!(cache.can_cache_exact_range(&key, MAX_EXACT_RANGE_CACHE_ENTRY_SIZE)); + assert!(!cache.can_cache_exact_range(&key, MAX_EXACT_RANGE_CACHE_ENTRY_SIZE + 1)); + } + #[test] fn test_local_cache_config_uses_memory_when_enabled_without_directory() { let mut options = Options::new(); diff --git a/crates/paimon/src/io/cache/reader.rs b/crates/paimon/src/io/cache/reader.rs index d2008ab54..248297cc8 100644 --- a/crates/paimon/src/io/cache/reader.rs +++ b/crates/paimon/src/io/cache/reader.rs @@ -221,6 +221,9 @@ impl FileRead for CachedFileReader { message: "BLOB metadata range is too large".to_string(), source: None, })?; + if !self.cache.can_cache_exact_range(&key, expected_len) { + return self.delegate.read(range).await; + } if let Some(payload) = self .cache .get_block(&key, expected_len, &self.read_token) @@ -244,10 +247,11 @@ impl FileRead for CachedFileReader { } let payload = self.delegate.read(range).await?; if payload.len() == expected_len { - // Own only this range, even if the delegate returns a slice of a larger buffer. + let payload = Bytes::copy_from_slice(&payload); self.cache - .put_block(&key, Bytes::copy_from_slice(&payload), &self.read_token) + .put_block(&key, payload.clone(), &self.read_token) .await; + return Ok(payload); } Ok(payload) } @@ -368,6 +372,42 @@ mod tests { assert_eq!(delegate.reads.load(Ordering::SeqCst), 1); } + #[tokio::test] + async fn test_uncacheable_blob_metadata_reads_are_not_serialized() { + for disk in [false, true] { + let directory = tempfile::tempdir().unwrap(); + let delegate = Arc::new(ConcurrentCountingReader { + data: Bytes::from_static(b"abcdefgh"), + reads: AtomicUsize::new(0), + in_flight: AtomicUsize::new(0), + max_in_flight: AtomicUsize::new(0), + }); + let cache = Arc::new( + LocalCache::new(LocalCacheConfig { + dir: disk.then(|| directory.path().to_path_buf()), + namespace: "test".into(), + max_size: Some(1), + block_size: 1024, + whitelist: std::collections::HashSet::from([FileType::BlobMeta]), + }) + .unwrap(), + ); + let readers: Vec<_> = (0..16) + .map(|_| CachedFileReader::new(delegate.clone(), "data.blob", 8, cache.clone())) + .collect(); + let results = futures::future::join_all( + readers.iter().map(|reader| reader.read_blob_metadata(1..3)), + ) + .await; + + assert!(results + .into_iter() + .all(|result| result.unwrap() == b"bc"[..])); + assert_eq!(delegate.reads.load(Ordering::SeqCst), 16); + assert!(delegate.max_in_flight.load(Ordering::SeqCst) > 1); + } + } + #[allow(dead_code)] mod blob_utils { include!(concat!(env!("CARGO_MANIFEST_DIR"), "/blob_test_utils.rs")); @@ -554,6 +594,26 @@ mod tests { reads: AtomicUsize, } + #[derive(Debug)] + struct ConcurrentCountingReader { + data: Bytes, + reads: AtomicUsize, + in_flight: AtomicUsize, + max_in_flight: AtomicUsize, + } + + #[async_trait::async_trait] + impl FileRead for ConcurrentCountingReader { + async fn read(&self, range: Range) -> crate::Result { + self.reads.fetch_add(1, Ordering::SeqCst); + let in_flight = self.in_flight.fetch_add(1, Ordering::SeqCst) + 1; + self.max_in_flight.fetch_max(in_flight, Ordering::SeqCst); + tokio::time::sleep(std::time::Duration::from_millis(20)).await; + self.in_flight.fetch_sub(1, Ordering::SeqCst); + Ok(self.data.slice(range.start as usize..range.end as usize)) + } + } + #[async_trait::async_trait] impl FileRead for SlowCountingReader { async fn read(&self, range: Range) -> crate::Result { diff --git a/crates/paimon/src/io/cache/state.rs b/crates/paimon/src/io/cache/state.rs index 354714999..4c71bd962 100644 --- a/crates/paimon/src/io/cache/state.rs +++ b/crates/paimon/src/io/cache/state.rs @@ -58,18 +58,23 @@ impl BlockKey { } } - pub(super) fn memory_cost(&self, payload_len: usize) -> u64 { - let payload_len = payload_len as u64; + pub(super) fn checked_memory_cost(&self, payload_len: usize) -> Option { + let payload_len = u64::try_from(payload_len).ok()?; if self.exact_range { payload_len - .saturating_add((self.namespace.len() + self.path.len()) as u64) - .saturating_add(256) - .max(512) + .checked_add(u64::try_from(self.namespace.len()).ok()?)? + .checked_add(u64::try_from(self.path.len()).ok()?)? + .checked_add(256) + .map(|cost| cost.max(512)) } else { - payload_len + Some(payload_len) } } + pub(super) fn memory_cost(&self, payload_len: usize) -> u64 { + self.checked_memory_cost(payload_len).unwrap_or(u64::MAX) + } + pub(super) fn matches_path(&self, namespace: &str, path: &str) -> bool { self.namespace == namespace && self.path == path } From e7c63d69bf7cb64ada5f846360af2eabe87e706c Mon Sep 17 00:00:00 2001 From: xiaohongbo Date: Sat, 26 Sep 2026 23:25:46 -0700 Subject: [PATCH 03/12] docs: note BLOB metadata cache entry limit --- docs/src/getting-started.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/docs/src/getting-started.md b/docs/src/getting-started.md index cb917334b..d858a195e 100644 --- a/docs/src/getting-started.md +++ b/docs/src/getting-started.md @@ -330,7 +330,8 @@ let catalog = CatalogFactory::create(options).await?; without caching value bodies. Metadata ranges share the cache budget, with estimated memory overhead (at least 512 bytes) or disk allocation (at least 4 KiB) charged per entry. Built-in caches hold at most 65,536 entries and skip entries larger than the budget. `data` still enables -ordinary block caching. This caches bytes used to build descriptors, not decoded descriptors. +ordinary block caching. Exact metadata entries over 64 MiB bypass the cache. This caches bytes +used to build descriptors, not decoded descriptors. 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 From fefc91318db4606ad4441723a93ca53e02224171 Mon Sep 17 00:00:00 2001 From: xiaohongbo Date: Sun, 27 Sep 2026 00:30:42 -0700 Subject: [PATCH 04/12] docs: shorten BLOB metadata cache guidance --- docs/src/getting-started.md | 11 ++++------- 1 file changed, 4 insertions(+), 7 deletions(-) diff --git a/docs/src/getting-started.md b/docs/src/getting-started.md index d858a195e..9f188d126 100644 --- a/docs/src/getting-started.md +++ b/docs/src/getting-started.md @@ -322,16 +322,13 @@ 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.max-size` | unlimited | Cache budget, including BLOB metadata entry overhead. Accepts units such as `512 MiB` or `20 GiB`. | | `local-cache.block-size` | `1 MiB` | Block size used for cached range reads. | | `local-cache.whitelist` | `meta,global-index,blob-meta` | Cache categories: `meta`, `global-index`, `bucket-index`, `data`, `file-index`, and `blob-meta`. | -`blob-meta` caches exact BLOB footer/index and ARRAY/MAP metadata ranges, including MAP keys, -without caching value bodies. Metadata ranges share the cache budget, with estimated memory -overhead (at least 512 bytes) or disk allocation (at least 4 KiB) charged per entry. Built-in -caches hold at most 65,536 entries and skip entries larger than the budget. `data` still enables -ordinary block caching. Exact metadata entries over 64 MiB bypass the cache. This caches bytes -used to build descriptors, not decoded descriptors. +`blob-meta` caches BLOB footer/index and ARRAY/MAP metadata bytes, including MAP keys, +without value bodies. Entries share the cache budget, including overhead. Built-in caches +hold at most 65,536 entries; metadata entries exceeding the budget or 64 MiB are not cached. 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 From 9f92bc8027907d323b24469e74c88b7182a29626 Mon Sep 17 00:00:00 2001 From: xiaohongbo Date: Sun, 27 Sep 2026 00:47:26 -0700 Subject: [PATCH 05/12] fix(io): recover disk cache before metadata admission --- crates/paimon/src/io/cache/mod.rs | 22 +++++++++++++---- crates/paimon/src/io/cache/reader.rs | 36 +++++++++++++++++++++++++++- 2 files changed, 52 insertions(+), 6 deletions(-) diff --git a/crates/paimon/src/io/cache/mod.rs b/crates/paimon/src/io/cache/mod.rs index 9976dc08a..e94f437d9 100644 --- a/crates/paimon/src/io/cache/mod.rs +++ b/crates/paimon/src/io/cache/mod.rs @@ -131,7 +131,11 @@ impl LocalCache { key } - fn can_cache_exact_range(&self, key: &BlockKey, payload_len: usize) -> bool { + async fn can_cache_exact_range(&self, key: &BlockKey, payload_len: usize) -> bool { + // Enforce a reduced disk budget even when this request bypasses the cache. + if let CacheBackend::Disk(disk) = &self.backend { + disk.ensure_recovered().await; + } if !key.exact_range || payload_len > MAX_EXACT_RANGE_CACHE_ENTRY_SIZE { return false; } @@ -388,8 +392,8 @@ mod tests { .is_none()); } - #[test] - fn test_exact_range_cache_entry_has_a_hard_limit() { + #[tokio::test] + async fn test_exact_range_cache_entry_has_a_hard_limit() { let cache = LocalCache::new(LocalCacheConfig { dir: None, namespace: "test".into(), @@ -400,8 +404,16 @@ mod tests { .unwrap(); let key = cache.range_key("data.blob", &(0..MAX_EXACT_RANGE_CACHE_ENTRY_SIZE as u64)); - assert!(cache.can_cache_exact_range(&key, MAX_EXACT_RANGE_CACHE_ENTRY_SIZE)); - assert!(!cache.can_cache_exact_range(&key, MAX_EXACT_RANGE_CACHE_ENTRY_SIZE + 1)); + assert!( + cache + .can_cache_exact_range(&key, MAX_EXACT_RANGE_CACHE_ENTRY_SIZE) + .await + ); + assert!( + !cache + .can_cache_exact_range(&key, MAX_EXACT_RANGE_CACHE_ENTRY_SIZE + 1) + .await + ); } #[test] diff --git a/crates/paimon/src/io/cache/reader.rs b/crates/paimon/src/io/cache/reader.rs index 248297cc8..ffc84b86b 100644 --- a/crates/paimon/src/io/cache/reader.rs +++ b/crates/paimon/src/io/cache/reader.rs @@ -221,7 +221,7 @@ impl FileRead for CachedFileReader { message: "BLOB metadata range is too large".to_string(), source: None, })?; - if !self.cache.can_cache_exact_range(&key, expected_len) { + if !self.cache.can_cache_exact_range(&key, expected_len).await { return self.delegate.read(range).await; } if let Some(payload) = self @@ -372,6 +372,40 @@ mod tests { assert_eq!(delegate.reads.load(Ordering::SeqCst), 1); } + #[tokio::test] + async fn test_uncacheable_first_read_recovers_reduced_disk_budget() { + let directory = tempfile::tempdir().unwrap(); + let config = |max_size| LocalCacheConfig { + dir: Some(directory.path().to_path_buf()), + namespace: "restart".into(), + max_size: Some(max_size), + block_size: 1024, + whitelist: std::collections::HashSet::from([FileType::BlobMeta]), + }; + let cache = LocalCache::new(config(8192)).unwrap(); + let key = cache.range_key("data.blob", &(0..2)); + let token = cache.read_token("data.blob"); + cache + .put_block(&key, Bytes::from_static(b"ab"), &token) + .await; + let cached_file = directory + .path() + .join(super::super::CACHE_DIRECTORY_NAME) + .join(key.cache_relative_path()); + assert!(cached_file.exists()); + drop(cache); + + let cache = Arc::new(LocalCache::new(config(1)).unwrap()); + let delegate = Arc::new(CountingReader { + data: Bytes::from_static(b"abcdefgh"), + reads: AtomicUsize::new(0), + }); + let reader = CachedFileReader::new(delegate.clone(), "data.blob", 8, cache); + assert_eq!(reader.read_blob_metadata(2..4).await.unwrap(), b"cd"[..]); + assert_eq!(delegate.reads.load(Ordering::SeqCst), 1); + assert!(!cached_file.exists()); + } + #[tokio::test] async fn test_uncacheable_blob_metadata_reads_are_not_serialized() { for disk in [false, true] { From 79b483235f018e904d7a660670f276e360a4b756 Mon Sep 17 00:00:00 2001 From: xiaohongbo Date: Sun, 27 Sep 2026 01:27:56 -0700 Subject: [PATCH 06/12] fix(cache): make metadata eviction race safe --- crates/paimon/src/io/cache/disk.rs | 119 ++++++++++++++++---- crates/paimon/src/io/file_io.rs | 32 +++++- crates/paimon/src/table/data_file_reader.rs | 8 +- 3 files changed, 132 insertions(+), 27 deletions(-) diff --git a/crates/paimon/src/io/cache/disk.rs b/crates/paimon/src/io/cache/disk.rs index 6cbc0e9ec..f0593b6f3 100644 --- a/crates/paimon/src/io/cache/disk.rs +++ b/crates/paimon/src/io/cache/disk.rs @@ -60,6 +60,7 @@ impl std::fmt::Display for BlockDecodeError { pub(super) struct DiskCache { root: PathBuf, state: Mutex, + mutation: tokio::sync::Mutex<()>, recovered: tokio::sync::OnceCell<()>, coordinator: Arc, } @@ -107,6 +108,7 @@ impl DiskCache { max_size, ..CacheState::default() }), + mutation: tokio::sync::Mutex::new(()), recovered: tokio::sync::OnceCell::new(), coordinator: Arc::new(CacheCoordinator::default()), } @@ -299,15 +301,35 @@ impl DiskCache { } } drop(temporary_file); - if let Err(error) = tokio::fs::rename(&temporary, &path).await { - log::debug!( - "Failed to publish local cache block '{}': {error}", - path.display() - ); - let _ = tokio::fs::remove_file(temporary).await; - return; - } - self.record_entry_and_evict(key.clone(), encoded_size).await; + let retired = { + let _mutation = self.mutation.lock().await; + if let Err(error) = tokio::fs::rename(&temporary, &path).await { + log::debug!( + "Failed to publish local cache block '{}': {error}", + path.display() + ); + let _ = tokio::fs::remove_file(temporary).await; + return; + } + + let to_evict = { + let mut state = self.state.lock().unwrap_or_else(|error| error.into_inner()); + if state + .max_size + .is_some_and(|max_size| encoded_size > max_size) + { + remove_state_entry(&mut state, key); + let mut to_evict = vec![key.clone()]; + to_evict.extend(collect_evictions(&mut state)); + to_evict + } else { + insert_state_entry(&mut state, key.clone(), encoded_size); + collect_evictions(&mut state) + } + }; + self.retire_cache_files(to_evict).await + }; + self.remove_cache_files(retired).await; } pub(super) async fn invalidate_path(&self, namespace: &str, path: &str) { @@ -367,30 +389,50 @@ impl DiskCache { } } - async fn record_entry_and_evict(&self, key: BlockKey, encoded_size: u64) { - let to_evict = { - let mut state = self.state.lock().unwrap_or_else(|error| error.into_inner()); - insert_state_entry(&mut state, key, encoded_size); - collect_evictions(&mut state) - }; - self.remove_cache_files(to_evict).await; - } - async fn evict_over_limit(&self) { - let to_evict = { - let mut state = self.state.lock().unwrap_or_else(|error| error.into_inner()); - collect_evictions(&mut state) + let retired = { + let _mutation = self.mutation.lock().await; + let to_evict = { + let mut state = self.state.lock().unwrap_or_else(|error| error.into_inner()); + collect_evictions(&mut state) + }; + self.retire_cache_files(to_evict).await }; - self.remove_cache_files(to_evict).await; + self.remove_cache_files(retired).await; } - async fn remove_cache_files(&self, keys: Vec) { + async fn retire_cache_files(&self, keys: Vec) -> Vec { + let mut retired = Vec::with_capacity(keys.len()); for key in keys { let path = self.root.join(key.cache_relative_path()); + let Some(parent) = path.parent() else { + continue; + }; + let file_name = path + .file_name() + .map(|name| name.to_string_lossy()) + .unwrap_or_default(); + let retired_path = parent.join(format!(".{file_name}.tmp.{}", uuid::Uuid::new_v4())); + match tokio::fs::rename(&path, &retired_path).await { + Ok(()) => retired.push(retired_path), + Err(error) if error.kind() == std::io::ErrorKind::NotFound => {} + Err(error) => { + log::debug!( + "Failed to retire local cache block '{}': {error}", + path.display() + ); + } + } + } + retired + } + + async fn remove_cache_files(&self, paths: Vec) { + for path in paths { if let Err(error) = tokio::fs::remove_file(&path).await { if error.kind() != std::io::ErrorKind::NotFound { log::debug!( - "Failed to evict local cache block '{}': {error}", + "Failed to remove retired local cache block '{}': {error}", path.display() ); } @@ -1141,6 +1183,35 @@ mod tests { assert_eq!(cache.get_block(&second).await, Some(payload)); } + #[tokio::test] + async fn test_disk_cache_eviction_does_not_delete_republished_block() { + let directory = tempfile::tempdir().unwrap(); + let key = BlockKey::new("s3://bucket/table/data/data.blob", 4, 0); + let cache = DiskCache::new(directory.path(), None).unwrap(); + cache.put_block(&key, Bytes::from_static(b"old")).await; + + let retired = { + let _mutation = cache.mutation.lock().await; + let to_evict = { + let mut state = cache + .state + .lock() + .unwrap_or_else(|error| error.into_inner()); + remove_state_entry(&mut state, &key); + vec![key.clone()] + }; + cache.retire_cache_files(to_evict).await + }; + + cache.put_block(&key, Bytes::from_static(b"new")).await; + cache.remove_cache_files(retired).await; + + assert_eq!( + cache.get_block(&key).await, + Some(Bytes::from_static(b"new")) + ); + } + #[tokio::test] async fn test_disk_cache_lru_hit_refreshes_access_order() { let directory = tempfile::tempdir().unwrap(); diff --git a/crates/paimon/src/io/file_io.rs b/crates/paimon/src/io/file_io.rs index 57befdeec..0d6be79e5 100644 --- a/crates/paimon/src/io/file_io.rs +++ b/crates/paimon/src/io/file_io.rs @@ -909,13 +909,30 @@ impl InputFile { } pub async fn reader(&self) -> crate::Result { + self.reader_with_known_size(None).await + } + + pub(crate) async fn reader_with_file_size( + &self, + file_size: u64, + ) -> crate::Result { + self.reader_with_known_size(Some(file_size)).await + } + + async fn reader_with_known_size( + &self, + known_size: Option, + ) -> crate::Result { 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 { return Ok(InputFileReader::Direct(reader)); }; let read_token = cache.read_token(&cache_path); - let size = if let Some(size) = cache.file_size(&cache_path, &read_token).await { + let size = if let Some(size) = known_size { + cache.put_file_size(&cache_path, size, &read_token).await; + size + } else if let Some(size) = cache.file_size(&cache_path, &read_token).await { size } else { let size = op.stat(&relative_path).await?.content_length(); @@ -1660,6 +1677,19 @@ mod input_output_test { assert!(input.read().await.is_err()); } + #[tokio::test] + async fn test_reader_with_file_size_skips_source_stat() { + let source = tempfile::tempdir().unwrap(); + let cache_dir = tempfile::tempdir().unwrap(); + let path = source.path().join("missing.blob"); + let input = setup_cached_fs_file_io(cache_dir.path()) + .new_input(path.to_str().unwrap()) + .unwrap(); + + assert!(input.reader().await.is_err()); + assert!(input.reader_with_file_size(42).await.is_ok()); + } + 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(); diff --git a/crates/paimon/src/table/data_file_reader.rs b/crates/paimon/src/table/data_file_reader.rs index 58eb32122..eed05d863 100644 --- a/crates/paimon/src/table/data_file_reader.rs +++ b/crates/paimon/src/table/data_file_reader.rs @@ -612,7 +612,9 @@ impl DataFileReader { let schema_open_start = read_timing.as_ref().map(|_| Instant::now()); let input_file = file_io.new_input(&path_to_read)?; let open_start = read_timing.as_ref().map(|_| Instant::now()); - let file_reader = input_file.reader().await?; + let file_reader = input_file + .reader_with_file_size(file_meta.file_size as u64) + .await?; if let (Some(timing), Some(start)) = (read_timing.as_ref(), open_start) { timing.add_file_read(start.elapsed()); } @@ -910,7 +912,9 @@ impl DataFileReader { Ok(try_stream! { let input_file = file_io.new_input(&path_to_read)?; - let file_reader = input_file.reader().await?; + let file_reader = input_file + .reader_with_file_size(file_meta.file_size as u64) + .await?; let mut batch_stream = format_reader .read_batch_stream( From c593eb734b6c2456e97b952426d5b7feba815eb9 Mon Sep 17 00:00:00 2001 From: xiaohongbo Date: Sun, 27 Sep 2026 01:48:08 -0700 Subject: [PATCH 07/12] fix(cache): preserve republished entries after stale read failures --- crates/paimon/src/io/cache/disk.rs | 134 ++++++++++++++++++++++++----- 1 file changed, 113 insertions(+), 21 deletions(-) diff --git a/crates/paimon/src/io/cache/disk.rs b/crates/paimon/src/io/cache/disk.rs index f0593b6f3..131f0a07b 100644 --- a/crates/paimon/src/io/cache/disk.rs +++ b/crates/paimon/src/io/cache/disk.rs @@ -65,9 +65,15 @@ pub(super) struct DiskCache { coordinator: Arc, } +// Each publication gets a new identity, even when its key and size are unchanged. +#[derive(Debug)] +struct CacheEntry { + encoded_size: u64, +} + #[derive(Debug, Default)] struct CacheState { - entries: IndexMap, + entries: IndexMap>, paths: HashMap>, current_size: u64, max_size: Option, @@ -143,8 +149,8 @@ impl DiskCache { .state .lock() .unwrap_or_else(|error| error.into_inner()); - for (key, encoded_size) in recovered.entries { - insert_state_entry(&mut state, key, encoded_size); + for (key, entry) in recovered.entries { + insert_state_entry(&mut state, key, entry.encoded_size); } } Err(error) => { @@ -176,14 +182,23 @@ impl DiskCache { pub(super) async fn get_block(&self, key: &BlockKey) -> Option { self.ensure_recovered().await; - if !self.is_active(key) { - return None; - } + let entry = self.entry(key)?; + let path = self.root.join(key.cache_relative_path()); + self.complete_read(key, &entry, tokio::fs::read(&path).await) + .await + } + + async fn complete_read( + &self, + key: &BlockKey, + entry: &Arc, + result: std::io::Result>, + ) -> Option { let path = self.root.join(key.cache_relative_path()); - let encoded = match tokio::fs::read(&path).await { + let encoded = match result { Ok(encoded) => encoded, Err(error) if error.kind() == std::io::ErrorKind::NotFound => { - self.forget_entry(key); + self.discard_failed_read(key, entry).await; return None; } Err(error) => { @@ -195,20 +210,38 @@ impl DiskCache { } }; match decode_owned_block(key, encoded) { - Ok(payload) if self.touch_entry(key) => Some(payload), + Ok(payload) if self.touch_entry(key, entry) => Some(payload), Ok(_) => None, Err(error) => { log::debug!( "Discarding invalid local cache block '{}': {error}", path.display() ); - self.forget_entry(key); - let _ = tokio::fs::remove_file(path).await; + self.discard_failed_read(key, entry).await; None } } } + async fn discard_failed_read(&self, key: &BlockKey, observed: &Arc) { + let retired = { + let _mutation = self.mutation.lock().await; + { + let mut state = self.state.lock().unwrap_or_else(|error| error.into_inner()); + if !state + .entries + .get(key) + .is_some_and(|current| Arc::ptr_eq(current, observed)) + { + return; + } + remove_state_entry(&mut state, key); + } + self.retire_cache_files(vec![key.clone()]).await + }; + self.remove_cache_files(retired).await; + } + pub(super) async fn put_block(&self, key: &BlockKey, payload: Bytes) { self.ensure_recovered().await; let Some(mut encoded_size) = encoded_block_disk_cost(key, payload.len()) else { @@ -440,20 +473,31 @@ impl DiskCache { } } - fn is_active(&self, key: &BlockKey) -> bool { + fn entry(&self, key: &BlockKey) -> Option> { self.state .lock() .unwrap_or_else(|error| error.into_inner()) .entries - .contains_key(key) + .get(key) + .cloned() + } + + #[cfg(test)] + fn is_active(&self, key: &BlockKey) -> bool { + self.entry(key).is_some() } - fn touch_entry(&self, key: &BlockKey) -> bool { + fn touch_entry(&self, key: &BlockKey, observed: &Arc) -> bool { let mut state = self.state.lock().unwrap_or_else(|error| error.into_inner()); - let Some(encoded_size) = state.entries.shift_remove(key) else { + if !state + .entries + .get(key) + .is_some_and(|current| Arc::ptr_eq(current, observed)) + { return false; - }; - state.entries.insert(key.clone(), encoded_size); + } + let entry = state.entries.shift_remove(key).unwrap(); + state.entries.insert(key.clone(), entry); true } @@ -489,7 +533,7 @@ fn collect_evictions(state: &mut CacheState) -> Vec { let Some((eldest, size)) = state.entries.shift_remove_index(0) else { break; }; - state.current_size = state.current_size.saturating_sub(size); + state.current_size = state.current_size.saturating_sub(size.encoded_size); remove_path_index_entry(state, &eldest); to_evict.push(eldest); } @@ -512,20 +556,24 @@ 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) { - state.current_size = state.current_size.saturating_sub(previous_size); + state.current_size = state + .current_size + .saturating_sub(previous_size.encoded_size); } state .paths .entry(logical_path(&key)) .or_default() .insert(key.clone()); - state.entries.insert(key, encoded_size); + state + .entries + .insert(key, Arc::new(CacheEntry { 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) { - state.current_size = state.current_size.saturating_sub(encoded_size); + state.current_size = state.current_size.saturating_sub(encoded_size.encoded_size); remove_path_index_entry(state, key); } } @@ -1183,6 +1231,50 @@ mod tests { assert_eq!(cache.get_block(&second).await, Some(payload)); } + #[tokio::test] + async fn test_stale_failed_reads_preserve_republished_block() { + for corrupt in [false, true] { + let directory = tempfile::tempdir().unwrap(); + let key = BlockKey::new("data.blob", 4, 0); + let cache = DiskCache::new(directory.path(), None).unwrap(); + cache.put_block(&key, Bytes::from_static(b"old")).await; + let observed = cache.entry(&key).unwrap(); + let path = directory.path().join(key.cache_relative_path()); + // Pause A after the filesystem read but before failure cleanup. + if corrupt { + tokio::fs::write(&path, b"corrupt").await.unwrap(); + } else { + let _mutation = cache.mutation.lock().await; + cache.forget_entry(&key); + let retired = cache.retire_cache_files(vec![key.clone()]).await; + cache.remove_cache_files(retired).await; + } + let old_result = tokio::fs::read(&path).await; + if corrupt { + assert!(decode_owned_block(&key, old_result.as_ref().unwrap().clone()).is_err()); + } else { + assert_eq!( + old_result.as_ref().unwrap_err().kind(), + std::io::ErrorKind::NotFound + ); + } + // B republishes the same key and same payload length before A resumes. + cache.put_block(&key, Bytes::from_static(b"new")).await; + assert!(cache + .complete_read(&key, &observed, old_result) + .await + .is_none()); + assert_eq!( + cache.get_block(&key).await, + Some(Bytes::from_static(b"new")) + ); + assert!(path.exists()); + let state = cache.state.lock().unwrap(); + assert_eq!(state.entries.len(), 1); + assert_eq!(state.current_size, state.entries[&key].encoded_size); + } + } + #[tokio::test] async fn test_disk_cache_eviction_does_not_delete_republished_block() { let directory = tempfile::tempdir().unwrap(); From 8979eaa61ab397592c8a3822ab3ef8ce27c507d2 Mon Sep 17 00:00:00 2001 From: xiaohongbo Date: Sun, 27 Sep 2026 02:32:57 -0700 Subject: [PATCH 08/12] fix(io): separate cache read policy from object identity --- crates/paimon/src/io/cache/reader.rs | 49 ++++++++------- crates/paimon/src/io/file_io.rs | 91 +++++++++++++++++++++++++--- 2 files changed, 110 insertions(+), 30 deletions(-) diff --git a/crates/paimon/src/io/cache/reader.rs b/crates/paimon/src/io/cache/reader.rs index ffc84b86b..3fa37ffb3 100644 --- a/crates/paimon/src/io/cache/reader.rs +++ b/crates/paimon/src/io/cache/reader.rs @@ -27,7 +27,8 @@ use super::{CacheReadToken, LocalCache}; pub(crate) struct CachedFileReader { delegate: Arc, path: String, - file_size: u64, + // Some enables aligned blocks; None caches only explicit BLOB metadata ranges. + block_file_size: Option, cache: Arc, read_token: CacheReadToken, } @@ -42,30 +43,31 @@ impl CachedFileReader { ) -> Self { let path = path.into(); let read_token = cache.read_token(&path); - Self::new_with_token(delegate, path, file_size, cache, read_token) + let block_file_size = cache.cache_blocks(&path).then_some(file_size); + Self::new_with_token(delegate, path, block_file_size, cache, read_token) } pub(in crate::io) fn new_with_token( delegate: Arc, path: impl Into, - file_size: u64, + block_file_size: Option, cache: Arc, read_token: CacheReadToken, ) -> Self { Self { delegate, path: path.into(), - file_size, + block_file_size, cache, read_token, } } - async fn read_block(&self, block_index: u64) -> crate::Result { + async fn read_block(&self, block_index: u64, file_size: u64) -> crate::Result { let block_size = self.cache.block_size(); let key = self.cache.block_key(&self.path, block_index); let start = block_index * block_size; - let end = start.saturating_add(block_size).min(self.file_size); + let end = start.saturating_add(block_size).min(file_size); let expected_len = usize::try_from(end - start).map_err(|_| crate::Error::DataInvalid { message: format!("Cache block is too large for '{}'", self.path), source: None, @@ -130,21 +132,24 @@ impl CachedFileReader { } pub(crate) async fn read_full(&self) -> crate::Result { - if self.file_size == 0 { + let file_size = self + .block_file_size + .expect("full reads require block caching"); + if file_size == 0 { return Ok(Bytes::new()); } - if let Some(payload) = self.read_cached_full().await { + if let Some(payload) = self.read_cached_full(file_size).await { return Ok(payload); } let first_key = self.cache.block_key(&self.path, 0); let load_lock = self.cache.block_load_lock(&first_key).await; let load_guard = load_lock.lock().await; - let result = if let Some(payload) = self.read_cached_full().await { + let result = if let Some(payload) = self.read_cached_full(file_size).await { Ok(payload) } else { - match self.delegate.read(0..self.file_size).await { - Ok(payload) if payload.len() as u64 == self.file_size => { + match self.delegate.read(0..file_size).await { + Ok(payload) if payload.len() as u64 == file_size => { let chunk_size = usize::try_from(self.cache.block_size()).map_err(|_| { crate::Error::DataInvalid { message: format!("Cache block size is too large for '{}'", self.path), @@ -169,7 +174,7 @@ impl CachedFileReader { "Source file '{}' has length {}, expected {}", self.path, payload.len(), - self.file_size + file_size ), source: None, }), @@ -183,15 +188,15 @@ impl CachedFileReader { result } - async fn read_cached_full(&self) -> Option { + async fn read_cached_full(&self, file_size: u64) -> Option { let block_size = self.cache.block_size(); - let block_count = self.file_size.div_ceil(block_size); - let output_len = usize::try_from(self.file_size).ok()?; + let block_count = file_size.div_ceil(block_size); + let output_len = usize::try_from(file_size).ok()?; let mut output = BytesMut::with_capacity(output_len); for block_index in 0..block_count { let key = self.cache.block_key(&self.path, block_index); let start = block_index * block_size; - let expected_len = (self.file_size - start).min(block_size) as usize; + let expected_len = (file_size - start).min(block_size) as usize; let payload = self .cache .get_block(&key, expected_len, &self.read_token) @@ -209,10 +214,10 @@ impl CachedFileReader { #[async_trait::async_trait] impl FileRead for CachedFileReader { async fn read_blob_metadata(&self, range: Range) -> crate::Result { - if !self.cache.cache_blob_metadata(&self.path) || self.cache.cache_blocks(&self.path) { + if self.block_file_size.is_some() { return self.read(range).await; } - if range.start >= range.end || range.end > self.file_size { + if range.start >= range.end { return self.delegate.read(range).await; } let key = self.cache.range_key(&self.path, &range); @@ -262,10 +267,10 @@ impl FileRead for CachedFileReader { } async fn read(&self, range: Range) -> crate::Result { - if !self.cache.cache_blocks(&self.path) { + let Some(file_size) = self.block_file_size else { return self.delegate.read(range).await; - } - if range.start > range.end || range.end > self.file_size { + }; + if range.start > range.end || range.end > file_size { return self.delegate.read(range).await; } let end = range.end; @@ -284,7 +289,7 @@ impl FileRead for CachedFileReader { let mut output = BytesMut::with_capacity(output_len); for block_index in first_block..=last_block { - let block = self.read_block(block_index).await?; + let block = self.read_block(block_index, file_size).await?; let block_start = block_index * block_size; let copy_start = range.start.max(block_start) - block_start; let copy_end = end.min(block_start + block.len() as u64) - block_start; diff --git a/crates/paimon/src/io/file_io.rs b/crates/paimon/src/io/file_io.rs index 0d6be79e5..aef5235da 100644 --- a/crates/paimon/src/io/file_io.rs +++ b/crates/paimon/src/io/file_io.rs @@ -903,9 +903,15 @@ impl InputFile { size }; let delegate = Arc::new(op.reader(&relative_path).await?); - CachedFileReader::new_with_token(delegate, &cache_path, size, cache.clone(), read_token) - .read_full() - .await + CachedFileReader::new_with_token( + delegate, + &cache_path, + Some(size), + cache.clone(), + read_token, + ) + .read_full() + .await } pub async fn reader(&self) -> crate::Result { @@ -929,15 +935,19 @@ impl InputFile { return Ok(InputFileReader::Direct(reader)); }; let read_token = cache.read_token(&cache_path); - let size = if let Some(size) = known_size { + // Classify the logical path, never the opaque cache identity. Exact metadata + // ranges do not need the file size; body reads must not trigger a stat. + let size = if !cache.cache_blocks(&self.path) { + None + } else if let Some(size) = known_size { cache.put_file_size(&cache_path, size, &read_token).await; - size + Some(size) } else if let Some(size) = cache.file_size(&cache_path, &read_token).await { - size + Some(size) } else { let size = op.stat(&relative_path).await?.content_length(); cache.put_file_size(&cache_path, size, &read_token).await; - size + Some(size) }; Ok(InputFileReader::Cached(CachedFileReader::new_with_token( Arc::new(reader), @@ -1677,11 +1687,76 @@ mod input_output_test { assert!(input.read().await.is_err()); } + #[tokio::test] + async fn test_blob_metadata_reader_needs_no_stat() { + let source = tempfile::tempdir().unwrap(); + let cache_dir = tempfile::tempdir().unwrap(); + let path = source.path().join("data.blob"); + let input = setup_cached_fs_file_io(cache_dir.path()) + .new_input(path.to_str().unwrap()) + .unwrap(); + // Opening the reader must succeed even when a source stat would fail. + assert!(input.metadata().await.is_err()); + let reader = input.reader().await.unwrap(); + std::fs::write(&path, b"metabody").unwrap(); + assert_eq!(reader.read(4..8).await.unwrap(), b"body"[..]); + assert_eq!(reader.read_blob_metadata(0..4).await.unwrap(), b"meta"[..]); + drop(reader); + std::fs::remove_file(&path).unwrap(); + let reader = input.reader().await.unwrap(); + assert_eq!(reader.read_blob_metadata(0..4).await.unwrap(), b"meta"[..]); + assert!(reader.read(4..8).await.is_err()); + } + + #[tokio::test] + async fn test_root_metadata_range_cache_uses_logical_path() { + for disk in [false, true] { + let directory = tempfile::tempdir().unwrap(); + let mut options = Options::new(); + options.set(CatalogOptions::LOCAL_CACHE_ENABLED, "true"); + if disk { + options.set( + CatalogOptions::LOCAL_CACHE_DIR, + directory.path().to_string_lossy(), + ); + } + options.set(CatalogOptions::LOCAL_CACHE_BLOCK_SIZE, "4"); + let cache = Arc::new( + LocalCache::new(LocalCacheConfig::from_options(&options).unwrap().unwrap()) + .unwrap(), + ); + let file_io = FileIOBuilder::new("memory") + .with_local_cache(cache) + .build() + .unwrap(); + for path in [ + "memory:/snapshot-1", + "memory:/schema-1", + "memory:/snapshot/snapshot-1", + ] { + file_io + .new_output(path) + .unwrap() + .write(Bytes::from_static(b"metadata")) + .await + .unwrap(); + let input = file_io.new_input(path).unwrap(); + let reader = input.reader().await.unwrap(); + assert_eq!(reader.read(1..7).await.unwrap(), b"etadat"[..]); + // Delete the source directly, without invalidating the cache. + let (op, relative_path, _) = input.source.resolve(path).await.unwrap(); + op.delete(&relative_path).await.unwrap(); + let reader = input.reader().await.unwrap(); + assert_eq!(reader.read(1..7).await.unwrap(), b"etadat"[..], "{path}"); + } + } + } + #[tokio::test] async fn test_reader_with_file_size_skips_source_stat() { let source = tempfile::tempdir().unwrap(); let cache_dir = tempfile::tempdir().unwrap(); - let path = source.path().join("missing.blob"); + let path = source.path().join("snapshot-1"); let input = setup_cached_fs_file_io(cache_dir.path()) .new_input(path.to_str().unwrap()) .unwrap(); From f24b39467ccb48ba40b92215f58ea00e959b08d5 Mon Sep 17 00:00:00 2001 From: xiaohongbo Date: Sun, 27 Sep 2026 02:57:03 -0700 Subject: [PATCH 09/12] test(io): normalize metadata path for Windows --- crates/paimon/src/io/file_io.rs | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/crates/paimon/src/io/file_io.rs b/crates/paimon/src/io/file_io.rs index aef5235da..5db8e1086 100644 --- a/crates/paimon/src/io/file_io.rs +++ b/crates/paimon/src/io/file_io.rs @@ -1757,8 +1757,10 @@ mod input_output_test { let source = tempfile::tempdir().unwrap(); let cache_dir = tempfile::tempdir().unwrap(); let path = source.path().join("snapshot-1"); + // Use a logical path so metadata classification is identical on Windows. + let location = path.to_string_lossy().replace('\\', "/"); let input = setup_cached_fs_file_io(cache_dir.path()) - .new_input(path.to_str().unwrap()) + .new_input(&location) .unwrap(); assert!(input.reader().await.is_err()); From fe998ee2507098859280c4216bc0343ad35daf72 Mon Sep 17 00:00:00 2001 From: xiaohongbo Date: Sun, 27 Sep 2026 06:05:31 -0700 Subject: [PATCH 10/12] feat(core): replace BLOB metadata cache with Parquet-only selection --- crates/paimon/src/arrow/format/blob.rs | 8 +- crates/paimon/src/io/cache/disk.rs | 472 ++++---------------- crates/paimon/src/io/cache/file_type.rs | 14 +- crates/paimon/src/io/cache/memory.rs | 76 +--- crates/paimon/src/io/cache/mod.rs | 77 +--- crates/paimon/src/io/cache/reader.rs | 386 +--------------- crates/paimon/src/io/cache/state.rs | 28 -- crates/paimon/src/io/file_io.rs | 201 +++------ crates/paimon/src/table/data_file_reader.rs | 15 +- docs/src/getting-started.md | 10 +- 10 files changed, 196 insertions(+), 1091 deletions(-) diff --git a/crates/paimon/src/arrow/format/blob.rs b/crates/paimon/src/arrow/format/blob.rs index b29634522..e15ef57b4 100644 --- a/crates/paimon/src/arrow/format/blob.rs +++ b/crates/paimon/src/arrow/format/blob.rs @@ -1173,7 +1173,7 @@ async fn read_blob_array_range( ) -> crate::Result { let expected_length = range.end - range.start; let bytes = reader - .read_blob_metadata(range.clone()) + .read(range.clone()) .await .map_err(|e| Error::UnexpectedError { message: format!("Failed to read ARRAY {part} range {range:?}: {e}"), @@ -1613,7 +1613,7 @@ async fn read_blob_map_range( part: &str, ) -> crate::Result { let expected = range.end - range.start; - let bytes = reader.read_blob_metadata(range.clone()).await?; + let bytes = reader.read(range.clone()).await?; if bytes.len() as u64 != expected { return Err(Error::DataInvalid { message: format!( @@ -1670,7 +1670,7 @@ impl BlobFileIndex { } let footer = reader - .read_blob_metadata(file_size - BLOB_FOOTER_SIZE..file_size) + .read(file_size - BLOB_FOOTER_SIZE..file_size) .await .map_err(|e| Error::UnexpectedError { message: format!("Failed to read blob footer: {e}"), @@ -1712,7 +1712,7 @@ impl BlobFileIndex { let index_start = file_size - BLOB_FOOTER_SIZE - index_length; let data_region_end = index_start; let index_bytes = reader - .read_blob_metadata(index_start..index_start + index_length) + .read(index_start..index_start + index_length) .await .map_err(|e| Error::UnexpectedError { message: format!("Failed to read blob index bytes: {e}"), diff --git a/crates/paimon/src/io/cache/disk.rs b/crates/paimon/src/io/cache/disk.rs index 131f0a07b..72cb84f9e 100644 --- a/crates/paimon/src/io/cache/disk.rs +++ b/crates/paimon/src/io/cache/disk.rs @@ -27,15 +27,15 @@ use tokio::io::AsyncWriteExt; use super::state::{BlockKey, CacheCoordinator, LogicalPath}; const CACHE_MAGIC: &[u8; 8] = b"PAIMONLC"; -const CACHE_FORMAT_VERSION: u8 = 3; -const FIXED_HEADER_LEN: usize = CACHE_MAGIC.len() + 2 + 4 + 4 + 8 + 8 + 8; +const CACHE_FORMAT_VERSION: u8 = 2; +const FIXED_HEADER_LEN: usize = CACHE_MAGIC.len() + 1 + 4 + 4 + 8 + 8 + 8; const CHECKSUM_LEN: usize = 4; const MAX_CACHE_KEY_HEADER_LEN: usize = 1024 * 1024; impl BlockKey { pub(super) fn cache_relative_path(&self) -> PathBuf { let mut digest = Sha256::new(); - digest.update([CACHE_FORMAT_VERSION, u8::from(self.exact_range)]); + digest.update([CACHE_FORMAT_VERSION]); digest.update((self.namespace.len() as u64).to_le_bytes()); digest.update(self.namespace.as_bytes()); digest.update((self.path.len() as u64).to_le_bytes()); @@ -60,20 +60,13 @@ impl std::fmt::Display for BlockDecodeError { pub(super) struct DiskCache { root: PathBuf, state: Mutex, - mutation: tokio::sync::Mutex<()>, recovered: tokio::sync::OnceCell<()>, coordinator: Arc, } -// Each publication gets a new identity, even when its key and size are unchanged. -#[derive(Debug)] -struct CacheEntry { - encoded_size: u64, -} - #[derive(Debug, Default)] struct CacheState { - entries: IndexMap>, + entries: IndexMap, paths: HashMap>, current_size: u64, max_size: Option, @@ -114,7 +107,6 @@ impl DiskCache { max_size, ..CacheState::default() }), - mutation: tokio::sync::Mutex::new(()), recovered: tokio::sync::OnceCell::new(), coordinator: Arc::new(CacheCoordinator::default()), } @@ -149,8 +141,8 @@ impl DiskCache { .state .lock() .unwrap_or_else(|error| error.into_inner()); - for (key, entry) in recovered.entries { - insert_state_entry(&mut state, key, entry.encoded_size); + for (key, encoded_size) in recovered.entries { + insert_state_entry(&mut state, key, encoded_size); } } Err(error) => { @@ -169,36 +161,16 @@ impl DiskCache { self.coordinator.clone() } - pub(super) fn can_store(&self, key: &BlockKey, payload_len: usize) -> bool { - let Some(encoded_size) = encoded_block_disk_cost(key, payload_len) else { - return false; - }; - self.state - .lock() - .unwrap_or_else(|error| error.into_inner()) - .max_size - .is_none_or(|max_size| encoded_size <= max_size) - } - pub(super) async fn get_block(&self, key: &BlockKey) -> Option { self.ensure_recovered().await; - let entry = self.entry(key)?; - let path = self.root.join(key.cache_relative_path()); - self.complete_read(key, &entry, tokio::fs::read(&path).await) - .await - } - - async fn complete_read( - &self, - key: &BlockKey, - entry: &Arc, - result: std::io::Result>, - ) -> Option { + if !self.is_active(key) { + return None; + } let path = self.root.join(key.cache_relative_path()); - let encoded = match result { + let encoded = match tokio::fs::read(&path).await { Ok(encoded) => encoded, Err(error) if error.kind() == std::io::ErrorKind::NotFound => { - self.discard_failed_read(key, entry).await; + self.forget_entry(key); return None; } Err(error) => { @@ -209,47 +181,25 @@ impl DiskCache { return None; } }; - match decode_owned_block(key, encoded) { - Ok(payload) if self.touch_entry(key, entry) => Some(payload), + match decode_block(key, &encoded) { + Ok(payload) if self.touch_entry(key) => Some(payload), Ok(_) => None, Err(error) => { log::debug!( "Discarding invalid local cache block '{}': {error}", path.display() ); - self.discard_failed_read(key, entry).await; + self.forget_entry(key); + let _ = tokio::fs::remove_file(path).await; None } } } - async fn discard_failed_read(&self, key: &BlockKey, observed: &Arc) { - let retired = { - let _mutation = self.mutation.lock().await; - { - let mut state = self.state.lock().unwrap_or_else(|error| error.into_inner()); - if !state - .entries - .get(key) - .is_some_and(|current| Arc::ptr_eq(current, observed)) - { - return; - } - remove_state_entry(&mut state, key); - } - self.retire_cache_files(vec![key.clone()]).await - }; - self.remove_cache_files(retired).await; - } - pub(super) async fn put_block(&self, key: &BlockKey, payload: Bytes) { self.ensure_recovered().await; - let Some(mut encoded_size) = encoded_block_disk_cost(key, payload.len()) else { - return; - }; - if key.exact_range && payload.len() > super::MAX_EXACT_RANGE_CACHE_ENTRY_SIZE { - return; - } + let encoded = encode_block(key, &payload); + let encoded_size = encoded.len() as u64; if self .state .lock() @@ -259,9 +209,6 @@ impl DiskCache { { return; } - let Some(header) = encode_block_header(key, payload.len()) else { - return; - }; let path = self.root.join(key.cache_relative_path()); let Some(parent) = path.parent() else { @@ -296,14 +243,8 @@ impl DiskCache { return; } }; - let mut checksum = crc32fast::Hasher::new(); - checksum.update(&header); - checksum.update(&payload); - let checksum = checksum.finalize().to_le_bytes(); let write_result = async { - temporary_file.write_all(&header).await?; - temporary_file.write_all(&payload).await?; - temporary_file.write_all(&checksum).await?; + temporary_file.write_all(&encoded).await?; // Tokio may return from write_all before the blocking write completes. temporary_file.flush().await } @@ -317,52 +258,16 @@ impl DiskCache { let _ = tokio::fs::remove_file(&temporary).await; return; } - if key.exact_range { - if let Ok(metadata) = temporary_file.metadata().await { - encoded_size = range_disk_cost(&metadata); - } - if self - .state - .lock() - .unwrap_or_else(|error| error.into_inner()) - .max_size - .is_some_and(|limit| encoded_size > limit) - { - drop(temporary_file); - let _ = tokio::fs::remove_file(&temporary).await; - return; - } - } drop(temporary_file); - let retired = { - let _mutation = self.mutation.lock().await; - if let Err(error) = tokio::fs::rename(&temporary, &path).await { - log::debug!( - "Failed to publish local cache block '{}': {error}", - path.display() - ); - let _ = tokio::fs::remove_file(temporary).await; - return; - } - - let to_evict = { - let mut state = self.state.lock().unwrap_or_else(|error| error.into_inner()); - if state - .max_size - .is_some_and(|max_size| encoded_size > max_size) - { - remove_state_entry(&mut state, key); - let mut to_evict = vec![key.clone()]; - to_evict.extend(collect_evictions(&mut state)); - to_evict - } else { - insert_state_entry(&mut state, key.clone(), encoded_size); - collect_evictions(&mut state) - } - }; - self.retire_cache_files(to_evict).await - }; - self.remove_cache_files(retired).await; + if let Err(error) = tokio::fs::rename(&temporary, &path).await { + log::debug!( + "Failed to publish local cache block '{}': {error}", + path.display() + ); + let _ = tokio::fs::remove_file(temporary).await; + return; + } + self.record_entry_and_evict(key.clone(), encoded_size).await; } pub(super) async fn invalidate_path(&self, namespace: &str, path: &str) { @@ -422,50 +327,30 @@ impl DiskCache { } } + async fn record_entry_and_evict(&self, key: BlockKey, encoded_size: u64) { + let to_evict = { + let mut state = self.state.lock().unwrap_or_else(|error| error.into_inner()); + insert_state_entry(&mut state, key, encoded_size); + collect_evictions(&mut state) + }; + self.remove_cache_files(to_evict).await; + } + async fn evict_over_limit(&self) { - let retired = { - let _mutation = self.mutation.lock().await; - let to_evict = { - let mut state = self.state.lock().unwrap_or_else(|error| error.into_inner()); - collect_evictions(&mut state) - }; - self.retire_cache_files(to_evict).await + let to_evict = { + let mut state = self.state.lock().unwrap_or_else(|error| error.into_inner()); + collect_evictions(&mut state) }; - self.remove_cache_files(retired).await; + self.remove_cache_files(to_evict).await; } - async fn retire_cache_files(&self, keys: Vec) -> Vec { - let mut retired = Vec::with_capacity(keys.len()); + async fn remove_cache_files(&self, keys: Vec) { for key in keys { let path = self.root.join(key.cache_relative_path()); - let Some(parent) = path.parent() else { - continue; - }; - let file_name = path - .file_name() - .map(|name| name.to_string_lossy()) - .unwrap_or_default(); - let retired_path = parent.join(format!(".{file_name}.tmp.{}", uuid::Uuid::new_v4())); - match tokio::fs::rename(&path, &retired_path).await { - Ok(()) => retired.push(retired_path), - Err(error) if error.kind() == std::io::ErrorKind::NotFound => {} - Err(error) => { - log::debug!( - "Failed to retire local cache block '{}': {error}", - path.display() - ); - } - } - } - retired - } - - async fn remove_cache_files(&self, paths: Vec) { - for path in paths { if let Err(error) = tokio::fs::remove_file(&path).await { if error.kind() != std::io::ErrorKind::NotFound { log::debug!( - "Failed to remove retired local cache block '{}': {error}", + "Failed to evict local cache block '{}': {error}", path.display() ); } @@ -473,31 +358,20 @@ impl DiskCache { } } - fn entry(&self, key: &BlockKey) -> Option> { + fn is_active(&self, key: &BlockKey) -> bool { self.state .lock() .unwrap_or_else(|error| error.into_inner()) .entries - .get(key) - .cloned() - } - - #[cfg(test)] - fn is_active(&self, key: &BlockKey) -> bool { - self.entry(key).is_some() + .contains_key(key) } - fn touch_entry(&self, key: &BlockKey, observed: &Arc) -> bool { + fn touch_entry(&self, key: &BlockKey) -> bool { let mut state = self.state.lock().unwrap_or_else(|error| error.into_inner()); - if !state - .entries - .get(key) - .is_some_and(|current| Arc::ptr_eq(current, observed)) - { + let Some(encoded_size) = state.entries.shift_remove(key) else { return false; - } - let entry = state.entries.shift_remove(key).unwrap(); - state.entries.insert(key.clone(), entry); + }; + state.entries.insert(key.clone(), encoded_size); true } @@ -524,16 +398,12 @@ fn prepare_cache_root(root: &Path) -> crate::Result { fn collect_evictions(state: &mut CacheState) -> Vec { let mut to_evict = Vec::new(); - { - while state.entries.len() > super::MAX_CACHE_ENTRIES - || state - .max_size - .is_some_and(|limit| state.current_size > limit) - { + 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 { break; }; - state.current_size = state.current_size.saturating_sub(size.encoded_size); + state.current_size = state.current_size.saturating_sub(size); remove_path_index_entry(state, &eldest); to_evict.push(eldest); } @@ -556,24 +426,20 @@ 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) { - state.current_size = state - .current_size - .saturating_sub(previous_size.encoded_size); + state.current_size = state.current_size.saturating_sub(previous_size); } state .paths .entry(logical_path(&key)) .or_default() .insert(key.clone()); - state - .entries - .insert(key, Arc::new(CacheEntry { encoded_size })); + 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) { - state.current_size = state.current_size.saturating_sub(encoded_size.encoded_size); + state.current_size = state.current_size.saturating_sub(encoded_size); remove_path_index_entry(state, key); } } @@ -728,36 +594,16 @@ fn collect_cache_file( return; } }; - let cost = if key.exact_range { - range_disk_cost(&metadata) - } else { - metadata.len() - }; discovered.push(( metadata .modified() .unwrap_or(std::time::SystemTime::UNIX_EPOCH), path, key, - cost, + metadata.len(), )); } -fn range_disk_cost(metadata: &std::fs::Metadata) -> u64 { - #[cfg(unix)] - { - use std::os::unix::fs::MetadataExt; - metadata - .len() - .max(metadata.blocks().saturating_mul(512)) - .max(4096) - } - #[cfg(not(unix))] - { - metadata.len().saturating_add(4095) / 4096 * 4096 - } -} - fn read_block_key_header(path: &Path, encoded_len: u64) -> Result { let mut file = std::fs::File::open(path).map_err(|_| BlockDecodeError("cache block cannot be opened"))?; @@ -794,81 +640,37 @@ fn is_cache_temporary_name(name: &str, shard: &str) -> bool { is_cache_block_name(digest, shard) && uuid::Uuid::parse_str(suffix).is_ok() } -fn encoded_block_len(key: &BlockKey, payload_len: usize) -> Option { - let key_len = key.namespace.len().checked_add(key.path.len())?; - if key_len > MAX_CACHE_KEY_HEADER_LEN - || u32::try_from(key.namespace.len()).is_err() - || u32::try_from(key.path.len()).is_err() - { - return None; - } - FIXED_HEADER_LEN - .checked_add(key_len)? - .checked_add(payload_len)? - .checked_add(CHECKSUM_LEN) -} - -fn encoded_block_disk_cost(key: &BlockKey, payload_len: usize) -> Option { - let encoded_len = u64::try_from(encoded_block_len(key, payload_len)?).ok()?; - if key.exact_range { - encoded_len - .checked_add(4095) - .map(|length| length / 4096 * 4096) - } else { - Some(encoded_len) - } -} - -fn encode_block_header(key: &BlockKey, payload_len: usize) -> Option> { +fn encode_block(key: &BlockKey, payload: &Bytes) -> Vec { let namespace = key.namespace.as_bytes(); let path = key.path.as_bytes(); - let encoded_len = encoded_block_len(key, payload_len)?; - let header_len = FIXED_HEADER_LEN - .checked_add(namespace.len())? - .checked_add(path.len())?; - let mut header = Vec::with_capacity(header_len); - header.extend_from_slice(CACHE_MAGIC); - header.push(CACHE_FORMAT_VERSION); - header.push(u8::from(key.exact_range)); - header.extend_from_slice(&u32::try_from(namespace.len()).ok()?.to_le_bytes()); - header.extend_from_slice(&u32::try_from(path.len()).ok()?.to_le_bytes()); - header.extend_from_slice(&key.block_size.to_le_bytes()); - header.extend_from_slice(&key.block_index.to_le_bytes()); - header.extend_from_slice(&u64::try_from(payload_len).ok()?.to_le_bytes()); - header.extend_from_slice(namespace); - header.extend_from_slice(path); - debug_assert_eq!(encoded_len, header.len() + payload_len + CHECKSUM_LEN); - Some(header) -} - -#[cfg(test)] -fn encode_block(key: &BlockKey, payload: &Bytes) -> Vec { - let mut encoded = encode_block_header(key, payload.len()).unwrap(); + let mut encoded = Vec::with_capacity( + FIXED_HEADER_LEN + namespace.len() + path.len() + payload.len() + CHECKSUM_LEN, + ); + encoded.extend_from_slice(CACHE_MAGIC); + encoded.push(CACHE_FORMAT_VERSION); + encoded.extend_from_slice(&(namespace.len() as u32).to_le_bytes()); + encoded.extend_from_slice(&(path.len() as u32).to_le_bytes()); + encoded.extend_from_slice(&key.block_size.to_le_bytes()); + encoded.extend_from_slice(&key.block_index.to_le_bytes()); + encoded.extend_from_slice(&(payload.len() as u64).to_le_bytes()); + encoded.extend_from_slice(namespace); + encoded.extend_from_slice(path); encoded.extend_from_slice(payload); let checksum = crc32fast::hash(&encoded); encoded.extend_from_slice(&checksum.to_le_bytes()); encoded } -#[cfg(test)] fn decode_block(key: &BlockKey, encoded: &[u8]) -> Result { - decode_bytes_block(key, Bytes::copy_from_slice(encoded)) -} - -fn decode_owned_block(key: &BlockKey, encoded: Vec) -> Result { - decode_bytes_block(key, Bytes::from(encoded)) -} - -fn decode_bytes_block(key: &BlockKey, encoded: Bytes) -> Result { - let (decoded_key, payload) = decode_bytes_block_any(encoded)?; + let (decoded_key, payload) = decode_block_any(encoded)?; if &decoded_key != key { return Err(BlockDecodeError("cache block key does not match")); } Ok(payload) } -fn decode_bytes_block_any(encoded: Bytes) -> Result<(BlockKey, Bytes), BlockDecodeError> { - let (key, layout) = decode_block_header(&encoded, encoded.len() as u64)?; +fn decode_block_any(encoded: &[u8]) -> Result<(BlockKey, Bytes), BlockDecodeError> { + let (key, layout) = decode_block_header(encoded, encoded.len() as u64)?; let payload_len = usize::try_from(layout.payload_len) .map_err(|_| BlockDecodeError("cache block payload is too large"))?; let payload_end = layout @@ -884,12 +686,14 @@ fn decode_bytes_block_any(encoded: Bytes) -> Result<(BlockKey, Bytes), BlockDeco return Err(BlockDecodeError("cache block checksum does not match")); } - Ok((key, encoded.slice(layout.header_len..payload_end))) + Ok(( + key, + Bytes::copy_from_slice(&encoded[layout.header_len..payload_end]), + )) } #[derive(Clone, Copy, Debug)] struct BlockLayout { - exact_range: bool, namespace_len: usize, path_len: usize, block_size: u64, @@ -912,12 +716,7 @@ fn decode_block_layout( return Err(BlockDecodeError("cache block version is unsupported")); } - let exact_range = match fixed_header[CACHE_MAGIC.len() + 1] { - 0 => false, - 1 => true, - _ => return Err(BlockDecodeError("invalid cache range kind")), - }; - let mut offset = CACHE_MAGIC.len() + 2; + let mut offset = CACHE_MAGIC.len() + 1; let namespace_len = read_u32(fixed_header, &mut offset)? as usize; let path_len = read_u32(fixed_header, &mut offset)? as usize; let block_size = read_u64(fixed_header, &mut offset)?; @@ -946,7 +745,6 @@ fn decode_block_layout( } Ok(BlockLayout { - exact_range, namespace_len, path_len, block_size, @@ -973,9 +771,10 @@ fn decode_block_header( let path = std::str::from_utf8(&header[namespace_end..path_end]) .map_err(|_| BlockDecodeError("cache block path is not UTF-8"))?; - let mut key = BlockKey::with_namespace(namespace, path, layout.block_size, layout.block_index); - key.exact_range = layout.exact_range; - Ok((key, layout)) + Ok(( + BlockKey::with_namespace(namespace, path, layout.block_size, layout.block_index), + layout, + )) } fn read_u32(encoded: &[u8], offset: &mut usize) -> Result { @@ -1005,36 +804,6 @@ mod tests { use super::*; use std::sync::Arc; - #[tokio::test] - async fn test_metadata_ranges_charge_disk_allocation_and_recover() { - let directory = tempfile::tempdir().unwrap(); - let cache = DiskCache::new(directory.path(), Some(8192)).unwrap(); - let manifest = BlockKey::new("manifest", 8, 0); - cache - .put_block(&manifest, Bytes::from_static(b"manifest")) - .await; - let mut key = BlockKey::new("data.blob", 16384, 0); - key.exact_range = true; - cache.put_block(&key, Bytes::from(vec![0; 16384])).await; - assert!(cache.get_block(&manifest).await.is_some()); - key.block_size = 1; - for offset in 0..100 { - key.block_index = offset; - cache.put_block(&key, Bytes::from_static(b"x")).await; - } - { - let state = cache.state.lock().unwrap(); - assert!(state.current_size <= 8192); - assert!(state.entries.len() <= 2); - } - drop(cache); - let cache = DiskCache::new(directory.path(), Some(8192)).unwrap(); - assert_eq!(cache.get_block(&key).await.unwrap(), b"x"[..]); - let state = cache.state.lock().unwrap(); - assert!(state.current_size <= 8192); - assert!(state.entries.len() <= 2); - } - #[test] fn test_disk_block_codec_round_trip() { let key = BlockKey::new("s3://bucket/table/snapshot/snapshot-1", 1024, 3); @@ -1046,14 +815,6 @@ mod tests { assert_eq!(decoded, payload); } - #[test] - fn test_disk_block_size_preflight_rejects_overflow() { - let mut key = BlockKey::new("data.blob", 1, 0); - key.exact_range = true; - - assert_eq!(encoded_block_disk_cost(&key, usize::MAX), None); - } - #[test] fn test_disk_block_codec_rejects_crc_corruption() { let key = BlockKey::new("s3://bucket/table/snapshot/snapshot-1", 1024, 3); @@ -1231,79 +992,6 @@ mod tests { assert_eq!(cache.get_block(&second).await, Some(payload)); } - #[tokio::test] - async fn test_stale_failed_reads_preserve_republished_block() { - for corrupt in [false, true] { - let directory = tempfile::tempdir().unwrap(); - let key = BlockKey::new("data.blob", 4, 0); - let cache = DiskCache::new(directory.path(), None).unwrap(); - cache.put_block(&key, Bytes::from_static(b"old")).await; - let observed = cache.entry(&key).unwrap(); - let path = directory.path().join(key.cache_relative_path()); - // Pause A after the filesystem read but before failure cleanup. - if corrupt { - tokio::fs::write(&path, b"corrupt").await.unwrap(); - } else { - let _mutation = cache.mutation.lock().await; - cache.forget_entry(&key); - let retired = cache.retire_cache_files(vec![key.clone()]).await; - cache.remove_cache_files(retired).await; - } - let old_result = tokio::fs::read(&path).await; - if corrupt { - assert!(decode_owned_block(&key, old_result.as_ref().unwrap().clone()).is_err()); - } else { - assert_eq!( - old_result.as_ref().unwrap_err().kind(), - std::io::ErrorKind::NotFound - ); - } - // B republishes the same key and same payload length before A resumes. - cache.put_block(&key, Bytes::from_static(b"new")).await; - assert!(cache - .complete_read(&key, &observed, old_result) - .await - .is_none()); - assert_eq!( - cache.get_block(&key).await, - Some(Bytes::from_static(b"new")) - ); - assert!(path.exists()); - let state = cache.state.lock().unwrap(); - assert_eq!(state.entries.len(), 1); - assert_eq!(state.current_size, state.entries[&key].encoded_size); - } - } - - #[tokio::test] - async fn test_disk_cache_eviction_does_not_delete_republished_block() { - let directory = tempfile::tempdir().unwrap(); - let key = BlockKey::new("s3://bucket/table/data/data.blob", 4, 0); - let cache = DiskCache::new(directory.path(), None).unwrap(); - cache.put_block(&key, Bytes::from_static(b"old")).await; - - let retired = { - let _mutation = cache.mutation.lock().await; - let to_evict = { - let mut state = cache - .state - .lock() - .unwrap_or_else(|error| error.into_inner()); - remove_state_entry(&mut state, &key); - vec![key.clone()] - }; - cache.retire_cache_files(to_evict).await - }; - - cache.put_block(&key, Bytes::from_static(b"new")).await; - cache.remove_cache_files(retired).await; - - assert_eq!( - cache.get_block(&key).await, - Some(Bytes::from_static(b"new")) - ); - } - #[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/file_type.rs b/crates/paimon/src/io/cache/file_type.rs index 91d2e2f97..082926fab 100644 --- a/crates/paimon/src/io/cache/file_type.rs +++ b/crates/paimon/src/io/cache/file_type.rs @@ -21,10 +21,10 @@ use std::collections::HashSet; pub(super) enum FileType { Meta, Data, + ParquetData, BucketIndex, GlobalIndex, FileIndex, - BlobMeta, } impl FileType { @@ -54,7 +54,11 @@ impl FileType { return Self::BucketIndex; } - Self::Data + if name.ends_with(".parquet") { + Self::ParquetData + } else { + Self::Data + } } pub(super) fn is_mutable(path: &str) -> bool { @@ -76,13 +80,13 @@ impl FileType { "global-index" => Some(Self::GlobalIndex), "bucket-index" => Some(Self::BucketIndex), "data" => Some(Self::Data), + "parquet-data" => Some(Self::ParquetData), "file-index" => Some(Self::FileIndex), - "blob-meta" => Some(Self::BlobMeta), "" => None, unknown => { log::warn!( "Unknown local-cache.whitelist value '{}'; supported values are \ - meta, global-index, bucket-index, data, file-index, blob-meta", + meta, global-index, bucket-index, data, parquet-data, file-index", unknown ); None @@ -121,7 +125,7 @@ mod tests { ), ( "s3://bucket/table/bucket-0/data-abc.parquet", - FileType::Data, + FileType::ParquetData, ), ]; diff --git a/crates/paimon/src/io/cache/memory.rs b/crates/paimon/src/io/cache/memory.rs index c8466dcde..c50cd91af 100644 --- a/crates/paimon/src/io/cache/memory.rs +++ b/crates/paimon/src/io/cache/memory.rs @@ -49,21 +49,8 @@ impl MemoryCache { state.entries.get(key).cloned() } - pub(super) fn can_store(&self, key: &BlockKey, payload_len: usize) -> bool { - let Some(payload_size) = key.checked_memory_cost(payload_len) else { - return false; - }; - self.state - .lock() - .unwrap_or_else(|error| error.into_inner()) - .max_size - .is_none_or(|max_size| payload_size <= max_size) - } - pub(super) fn put_block(&self, key: &BlockKey, payload: Bytes) { - let Some(payload_size) = key.checked_memory_cost(payload.len()) else { - return; - }; + let payload_size = payload.len() as u64; let mut state = self.state.lock().unwrap_or_else(|error| error.into_inner()); if state .max_size @@ -72,31 +59,24 @@ impl MemoryCache { return; } if let Some(previous) = state.entries.put(key.clone(), payload) { - state.current_size = state - .current_size - .saturating_sub(key.memory_cost(previous.len())); + state.current_size = state.current_size.saturating_sub(previous.len() as u64); } state.current_size = state.current_size.saturating_add(payload_size); - while state.entries.len() > super::MAX_CACHE_ENTRIES - || state - .max_size - .is_some_and(|max_size| state.current_size > max_size) + while state + .max_size + .is_some_and(|max_size| state.current_size > max_size) { - let Some((key, payload)) = state.entries.pop_lru() else { + let Some((_, payload)) = state.entries.pop_lru() else { break; }; - state.current_size = state - .current_size - .saturating_sub(key.memory_cost(payload.len())); + state.current_size = state.current_size.saturating_sub(payload.len() as u64); } } 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(key.memory_cost(payload.len())); + state.current_size = state.current_size.saturating_sub(payload.len() as u64); } } @@ -120,9 +100,7 @@ impl MemoryCache { .collect::>(); for key in keys { if let Some(payload) = state.entries.pop(&key) { - state.current_size = state - .current_size - .saturating_sub(key.memory_cost(payload.len())); + state.current_size = state.current_size.saturating_sub(payload.len() as u64); } } } @@ -132,42 +110,6 @@ impl MemoryCache { mod tests { use super::*; - #[test] - fn test_metadata_range_entry_count_is_bounded_without_byte_limit() { - let cache = MemoryCache::new(None); - let mut key = BlockKey::new("data.blob", 1, 0); - key.exact_range = true; - for offset in 0..super::super::MAX_CACHE_ENTRIES + 1 { - key.block_index = offset as u64; - cache.put_block(&key, Bytes::from_static(b"x")); - } - assert_eq!( - cache.state.lock().unwrap().entries.len(), - super::super::MAX_CACHE_ENTRIES - ); - key.block_index = 0; - assert!(cache.get_block(&key).is_none()); - } - - #[test] - fn test_metadata_ranges_have_bounded_cost_and_skip_oversized_entries() { - let cache = MemoryCache::new(Some(4096)); - let manifest = BlockKey::new("manifest", 8, 0); - cache.put_block(&manifest, Bytes::from_static(b"manifest")); - let mut key = BlockKey::new("data.blob", 8192, 0); - key.exact_range = true; - cache.put_block(&key, Bytes::from(vec![0; 8192])); - assert!(cache.get_block(&manifest).is_some()); - key.block_size = 1; - for offset in 0..1000 { - key.block_index = offset; - cache.put_block(&key, Bytes::from_static(b"x")); - } - let state = cache.state.lock().unwrap(); - assert!(state.current_size <= 4096); - assert!(state.entries.len() <= 8); - } - #[test] fn test_memory_cache_refreshes_lru_and_evicts_by_payload_bytes() { let cache = MemoryCache::new(Some(8)); diff --git a/crates/paimon/src/io/cache/mod.rs b/crates/paimon/src/io/cache/mod.rs index e94f437d9..f5447d9c6 100644 --- a/crates/paimon/src/io/cache/mod.rs +++ b/crates/paimon/src/io/cache/mod.rs @@ -34,9 +34,7 @@ use std::sync::Arc; use disk::DiskCache; pub(super) use reader::CachedFileReader; -const CACHE_DIRECTORY_NAME: &str = "paimon-local-cache-v3"; -const MAX_CACHE_ENTRIES: usize = 65_536; -const MAX_EXACT_RANGE_CACHE_ENTRY_SIZE: usize = 64 * 1024 * 1024; +const CACHE_DIRECTORY_NAME: &str = "paimon-local-cache-v2"; const DEFAULT_FILE_SIZE_CAPACITY: usize = 65_536; #[derive(Debug)] @@ -113,37 +111,10 @@ impl LocalCache { } pub(super) fn is_cacheable(&self, path: &str) -> bool { - !FileType::is_mutable(path) && (self.cache_blocks(path) || self.cache_blob_metadata(path)) - } - - pub(super) fn cache_blocks(&self, path: &str) -> bool { - self.whitelist.contains(&FileType::classify(path)) - } - - pub(super) fn cache_blob_metadata(&self, path: &str) -> bool { - path.ends_with(".blob") && self.whitelist.contains(&FileType::BlobMeta) - } - - fn range_key(&self, path: &str, range: &std::ops::Range) -> BlockKey { - let mut key = - BlockKey::with_namespace(&self.namespace, path, range.end - range.start, range.start); - key.exact_range = true; - key - } - - async fn can_cache_exact_range(&self, key: &BlockKey, payload_len: usize) -> bool { - // Enforce a reduced disk budget even when this request bypasses the cache. - if let CacheBackend::Disk(disk) = &self.backend { - disk.ensure_recovered().await; - } - if !key.exact_range || payload_len > MAX_EXACT_RANGE_CACHE_ENTRY_SIZE { - return false; - } - match &self.backend { - CacheBackend::Memory(memory) => memory.can_store(key, payload_len), - CacheBackend::Disk(disk) => disk.can_store(key, payload_len), - CacheBackend::External(_) => true, - } + let file_type = FileType::classify(path); + !FileType::is_mutable(path) + && (self.whitelist.contains(&file_type) + || (file_type == FileType::ParquetData && self.whitelist.contains(&FileType::Data))) } async fn get_block( @@ -161,7 +132,7 @@ impl LocalCache { CacheBackend::Memory(memory) => memory.get_block(key), CacheBackend::Disk(disk) => disk.get_block(key).await, CacheBackend::External(cache) => { - let start = key.offset()?; + let start = key.block_index.checked_mul(key.block_size)?; let length = u64::try_from(expected_len).ok()?; let end = start.checked_add(length)?; cache.get(&key.path, start..end).await @@ -184,7 +155,7 @@ impl LocalCache { CacheBackend::Memory(memory) => memory.put_block(key, payload), CacheBackend::Disk(disk) => disk.put_block(key, payload).await, CacheBackend::External(cache) => { - if let Some(offset) = key.offset() { + if let Some(offset) = key.block_index.checked_mul(key.block_size) { cache.put(&key.path, offset, payload).await; } } @@ -337,7 +308,7 @@ impl LocalCacheConfig { let whitelist = options .get(CatalogOptions::LOCAL_CACHE_WHITELIST) .map(String::as_str) - .unwrap_or("meta,global-index,blob-meta"); + .unwrap_or("meta,global-index"); Ok(Some(Self { dir, @@ -392,30 +363,6 @@ mod tests { .is_none()); } - #[tokio::test] - async fn test_exact_range_cache_entry_has_a_hard_limit() { - let cache = LocalCache::new(LocalCacheConfig { - dir: None, - namespace: "test".into(), - max_size: None, - block_size: 1024, - whitelist: HashSet::from([FileType::BlobMeta]), - }) - .unwrap(); - let key = cache.range_key("data.blob", &(0..MAX_EXACT_RANGE_CACHE_ENTRY_SIZE as u64)); - - assert!( - cache - .can_cache_exact_range(&key, MAX_EXACT_RANGE_CACHE_ENTRY_SIZE) - .await - ); - assert!( - !cache - .can_cache_exact_range(&key, MAX_EXACT_RANGE_CACHE_ENTRY_SIZE + 1) - .await - ); - } - #[test] fn test_local_cache_config_uses_memory_when_enabled_without_directory() { let mut options = Options::new(); @@ -428,7 +375,7 @@ mod tests { assert_eq!(config.block_size, 1024 * 1024); assert_eq!( config.whitelist, - HashSet::from([FileType::Meta, FileType::GlobalIndex, FileType::BlobMeta]) + HashSet::from([FileType::Meta, FileType::GlobalIndex]) ); } @@ -447,11 +394,7 @@ mod tests { assert_eq!(config.block_size, 1024 * 1024); assert_eq!( config.whitelist, - std::collections::HashSet::from([ - FileType::Meta, - FileType::GlobalIndex, - FileType::BlobMeta - ]) + std::collections::HashSet::from([FileType::Meta, FileType::GlobalIndex]) ); } diff --git a/crates/paimon/src/io/cache/reader.rs b/crates/paimon/src/io/cache/reader.rs index 3fa37ffb3..240b163d1 100644 --- a/crates/paimon/src/io/cache/reader.rs +++ b/crates/paimon/src/io/cache/reader.rs @@ -27,8 +27,7 @@ use super::{CacheReadToken, LocalCache}; pub(crate) struct CachedFileReader { delegate: Arc, path: String, - // Some enables aligned blocks; None caches only explicit BLOB metadata ranges. - block_file_size: Option, + file_size: u64, cache: Arc, read_token: CacheReadToken, } @@ -43,31 +42,30 @@ impl CachedFileReader { ) -> Self { let path = path.into(); let read_token = cache.read_token(&path); - let block_file_size = cache.cache_blocks(&path).then_some(file_size); - Self::new_with_token(delegate, path, block_file_size, cache, read_token) + Self::new_with_token(delegate, path, file_size, cache, read_token) } pub(in crate::io) fn new_with_token( delegate: Arc, path: impl Into, - block_file_size: Option, + file_size: u64, cache: Arc, read_token: CacheReadToken, ) -> Self { Self { delegate, path: path.into(), - block_file_size, + file_size, cache, read_token, } } - async fn read_block(&self, block_index: u64, file_size: u64) -> crate::Result { + async fn read_block(&self, block_index: u64) -> crate::Result { let block_size = self.cache.block_size(); let key = self.cache.block_key(&self.path, block_index); let start = block_index * block_size; - let end = start.saturating_add(block_size).min(file_size); + let end = start.saturating_add(block_size).min(self.file_size); let expected_len = usize::try_from(end - start).map_err(|_| crate::Error::DataInvalid { message: format!("Cache block is too large for '{}'", self.path), source: None, @@ -132,24 +130,21 @@ impl CachedFileReader { } pub(crate) async fn read_full(&self) -> crate::Result { - let file_size = self - .block_file_size - .expect("full reads require block caching"); - if file_size == 0 { + if self.file_size == 0 { return Ok(Bytes::new()); } - if let Some(payload) = self.read_cached_full(file_size).await { + if let Some(payload) = self.read_cached_full().await { return Ok(payload); } let first_key = self.cache.block_key(&self.path, 0); let load_lock = self.cache.block_load_lock(&first_key).await; let load_guard = load_lock.lock().await; - let result = if let Some(payload) = self.read_cached_full(file_size).await { + let result = if let Some(payload) = self.read_cached_full().await { Ok(payload) } else { - match self.delegate.read(0..file_size).await { - Ok(payload) if payload.len() as u64 == file_size => { + match self.delegate.read(0..self.file_size).await { + Ok(payload) if payload.len() as u64 == self.file_size => { let chunk_size = usize::try_from(self.cache.block_size()).map_err(|_| { crate::Error::DataInvalid { message: format!("Cache block size is too large for '{}'", self.path), @@ -174,7 +169,7 @@ impl CachedFileReader { "Source file '{}' has length {}, expected {}", self.path, payload.len(), - file_size + self.file_size ), source: None, }), @@ -188,15 +183,15 @@ impl CachedFileReader { result } - async fn read_cached_full(&self, file_size: u64) -> Option { + async fn read_cached_full(&self) -> Option { let block_size = self.cache.block_size(); - let block_count = file_size.div_ceil(block_size); - let output_len = usize::try_from(file_size).ok()?; + let block_count = self.file_size.div_ceil(block_size); + let output_len = usize::try_from(self.file_size).ok()?; let mut output = BytesMut::with_capacity(output_len); for block_index in 0..block_count { let key = self.cache.block_key(&self.path, block_index); let start = block_index * block_size; - let expected_len = (file_size - start).min(block_size) as usize; + let expected_len = (self.file_size - start).min(block_size) as usize; let payload = self .cache .get_block(&key, expected_len, &self.read_token) @@ -213,64 +208,8 @@ impl CachedFileReader { #[async_trait::async_trait] impl FileRead for CachedFileReader { - async fn read_blob_metadata(&self, range: Range) -> crate::Result { - if self.block_file_size.is_some() { - return self.read(range).await; - } - if range.start >= range.end { - return self.delegate.read(range).await; - } - let key = self.cache.range_key(&self.path, &range); - let expected_len = - usize::try_from(range.end - range.start).map_err(|_| crate::Error::DataInvalid { - message: "BLOB metadata range is too large".to_string(), - source: None, - })?; - if !self.cache.can_cache_exact_range(&key, expected_len).await { - return self.delegate.read(range).await; - } - if let Some(payload) = self - .cache - .get_block(&key, expected_len, &self.read_token) - .await - { - if payload.len() == expected_len { - return Ok(payload); - } - } - let load_lock = self.cache.block_load_lock(&key).await; - let guard = load_lock.lock().await; - let result = async { - if let Some(payload) = self - .cache - .get_block(&key, expected_len, &self.read_token) - .await - { - if payload.len() == expected_len { - return Ok(payload); - } - } - let payload = self.delegate.read(range).await?; - if payload.len() == expected_len { - let payload = Bytes::copy_from_slice(&payload); - self.cache - .put_block(&key, payload.clone(), &self.read_token) - .await; - return Ok(payload); - } - Ok(payload) - } - .await; - drop(guard); - self.cache.release_block_load_lock(&key, &load_lock).await; - result - } - async fn read(&self, range: Range) -> crate::Result { - let Some(file_size) = self.block_file_size else { - return self.delegate.read(range).await; - }; - if range.start > range.end || range.end > file_size { + if range.start > range.end || range.end > self.file_size { return self.delegate.read(range).await; } let end = range.end; @@ -289,7 +228,7 @@ impl FileRead for CachedFileReader { let mut output = BytesMut::with_capacity(output_len); for block_index in first_block..=last_block { - let block = self.read_block(block_index, file_size).await?; + let block = self.read_block(block_index).await?; let block_start = block_index * block_size; let copy_start = range.start.max(block_start) - block_start; let copy_end = end.min(block_start + block.len() as u64) - block_start; @@ -309,275 +248,6 @@ mod tests { use crate::common::{CatalogOptions, Options}; use crate::io::cache::create_local_cache; - #[tokio::test] - async fn test_blob_metadata_exact_ranges_and_body_bypass() { - for disk in [false, true] { - let directory = tempfile::tempdir().unwrap(); - let config = || LocalCacheConfig { - dir: disk.then(|| directory.path().to_path_buf()), - namespace: "blob-test".into(), - max_size: Some(64 * 1024), - block_size: 1024, - whitelist: std::collections::HashSet::from([FileType::BlobMeta]), - }; - let cache = Arc::new(LocalCache::new(config()).unwrap()); - let delegate = Arc::new(CountingReader { - data: Bytes::from_static(b"abcdefgh"), - reads: AtomicUsize::new(0), - }); - let reader = CachedFileReader::new(delegate.clone(), "data.blob", 8, cache.clone()); - assert_eq!(reader.read_blob_metadata(1..3).await.unwrap(), b"bc"[..]); - assert_eq!(reader.read_blob_metadata(1..4).await.unwrap(), b"bcd"[..]); - assert_eq!(reader.read_blob_metadata(1..3).await.unwrap(), b"bc"[..]); - assert_eq!(delegate.reads.load(Ordering::SeqCst), 2); - // Values never enter the metadata cache. - reader.read(4..8).await.unwrap(); - reader.read(4..8).await.unwrap(); - assert_eq!(delegate.reads.load(Ordering::SeqCst), 4); - drop(reader); - drop(cache); - if disk { - let cache = Arc::new(LocalCache::new(config()).unwrap()); - let reader = CachedFileReader::new(delegate.clone(), "data.blob", 8, cache.clone()); - assert_eq!(reader.read_blob_metadata(1..3).await.unwrap(), b"bc"[..]); - assert_eq!(delegate.reads.load(Ordering::SeqCst), 4); - cache.invalidate_path("data.blob").await; - let reader = CachedFileReader::new(delegate.clone(), "data.blob", 8, cache); - reader.read_blob_metadata(1..3).await.unwrap(); - assert_eq!(delegate.reads.load(Ordering::SeqCst), 5); - } - } - } - - #[tokio::test] - async fn test_blob_metadata_cold_reads_are_coalesced() { - let delegate = Arc::new(SlowCountingReader { - data: Bytes::from_static(b"abcdefgh"), - reads: AtomicUsize::new(0), - }); - let cache = Arc::new( - LocalCache::new(LocalCacheConfig { - dir: None, - namespace: "test".into(), - max_size: Some(65536), - block_size: 1024, - whitelist: std::collections::HashSet::from([FileType::BlobMeta]), - }) - .unwrap(), - ); - let readers: Vec<_> = (0..16) - .map(|_| CachedFileReader::new(delegate.clone(), "data.blob", 8, cache.clone())) - .collect(); - let results = - futures::future::join_all(readers.iter().map(|reader| reader.read_blob_metadata(1..3))) - .await; - assert!(results - .into_iter() - .all(|result| result.unwrap() == b"bc"[..])); - assert_eq!(delegate.reads.load(Ordering::SeqCst), 1); - } - - #[tokio::test] - async fn test_uncacheable_first_read_recovers_reduced_disk_budget() { - let directory = tempfile::tempdir().unwrap(); - let config = |max_size| LocalCacheConfig { - dir: Some(directory.path().to_path_buf()), - namespace: "restart".into(), - max_size: Some(max_size), - block_size: 1024, - whitelist: std::collections::HashSet::from([FileType::BlobMeta]), - }; - let cache = LocalCache::new(config(8192)).unwrap(); - let key = cache.range_key("data.blob", &(0..2)); - let token = cache.read_token("data.blob"); - cache - .put_block(&key, Bytes::from_static(b"ab"), &token) - .await; - let cached_file = directory - .path() - .join(super::super::CACHE_DIRECTORY_NAME) - .join(key.cache_relative_path()); - assert!(cached_file.exists()); - drop(cache); - - let cache = Arc::new(LocalCache::new(config(1)).unwrap()); - let delegate = Arc::new(CountingReader { - data: Bytes::from_static(b"abcdefgh"), - reads: AtomicUsize::new(0), - }); - let reader = CachedFileReader::new(delegate.clone(), "data.blob", 8, cache); - assert_eq!(reader.read_blob_metadata(2..4).await.unwrap(), b"cd"[..]); - assert_eq!(delegate.reads.load(Ordering::SeqCst), 1); - assert!(!cached_file.exists()); - } - - #[tokio::test] - async fn test_uncacheable_blob_metadata_reads_are_not_serialized() { - for disk in [false, true] { - let directory = tempfile::tempdir().unwrap(); - let delegate = Arc::new(ConcurrentCountingReader { - data: Bytes::from_static(b"abcdefgh"), - reads: AtomicUsize::new(0), - in_flight: AtomicUsize::new(0), - max_in_flight: AtomicUsize::new(0), - }); - let cache = Arc::new( - LocalCache::new(LocalCacheConfig { - dir: disk.then(|| directory.path().to_path_buf()), - namespace: "test".into(), - max_size: Some(1), - block_size: 1024, - whitelist: std::collections::HashSet::from([FileType::BlobMeta]), - }) - .unwrap(), - ); - let readers: Vec<_> = (0..16) - .map(|_| CachedFileReader::new(delegate.clone(), "data.blob", 8, cache.clone())) - .collect(); - let results = futures::future::join_all( - readers.iter().map(|reader| reader.read_blob_metadata(1..3)), - ) - .await; - - assert!(results - .into_iter() - .all(|result| result.unwrap() == b"bc"[..])); - assert_eq!(delegate.reads.load(Ordering::SeqCst), 16); - assert!(delegate.max_in_flight.load(Ordering::SeqCst) > 1); - } - } - - #[allow(dead_code)] - mod blob_utils { - include!(concat!(env!("CARGO_MANIFEST_DIR"), "/blob_test_utils.rs")); - } - - #[tokio::test] - async fn test_blob_descriptor_scans_cache_metadata_not_values() { - use crate::arrow::format::{blob::BlobFormatReader, FormatFileReader}; - use crate::spec::{ArrayType, BlobType, DataField, DataType, MapType, VarCharType}; - use futures::TryStreamExt; - use std::sync::Mutex; - - struct RecordingReader { - data: Bytes, - ranges: Mutex>>, - } - #[async_trait::async_trait] - impl FileRead for RecordingReader { - async fn read(&self, range: Range) -> crate::Result { - self.ranges.lock().unwrap().push(range.clone()); - Ok(self.data.slice(range.start as usize..range.end as usize)) - } - } - let blob_type = DataType::Blob(BlobType::new()); - let mut map_payload = Vec::new(); - map_payload.extend_from_slice(&0x4D424342_i32.to_le_bytes()); - map_payload.push(1); - map_payload.extend_from_slice(&1_i32.to_le_bytes()); - map_payload.extend_from_slice(b"keyhello"); - let key_index = blob_utils::encode_delta_varints(&[3]); - let value_index = blob_utils::encode_delta_varints(&[5]); - map_payload.extend_from_slice(&key_index); - map_payload.extend_from_slice(&value_index); - map_payload.extend_from_slice(&(key_index.len() as i32).to_le_bytes()); - map_payload.extend_from_slice(&(value_index.len() as i32).to_le_bytes()); - let cases = [ - ( - include_bytes!(concat!( - env!("CARGO_MANIFEST_DIR"), - "/testdata/blob/blob-basic.blob" - )) - .to_vec(), - blob_type.clone(), - ), - ( - include_bytes!(concat!( - env!("CARGO_MANIFEST_DIR"), - "/testdata/blob/blob-array.blob" - )) - .to_vec(), - DataType::Array(ArrayType::new(blob_type.clone())), - ), - ( - blob_utils::build_blob_file_bytes(&[Some(&map_payload)]), - DataType::Map(MapType::new( - DataType::VarChar(VarCharType::new(100).unwrap()), - blob_type, - )), - ), - ]; - for disk in [false, true] { - for (data, data_type) in &cases { - let directory = tempfile::tempdir().unwrap(); - let cache = Arc::new( - LocalCache::new(LocalCacheConfig { - dir: disk.then(|| directory.path().to_path_buf()), - namespace: "scan".into(), - max_size: Some(65536), - block_size: 1024, - whitelist: std::collections::HashSet::from([FileType::BlobMeta]), - }) - .unwrap(), - ); - let delegate = Arc::new(RecordingReader { - data: Bytes::copy_from_slice(data), - ranges: Mutex::new(Vec::new()), - }); - let fields = [DataField::new(0, "value".into(), data_type.clone())]; - let scan = |descriptor| { - let reader = CachedFileReader::new( - delegate.clone(), - "data.blob", - data.len() as u64, - cache.clone(), - ); - let fields = fields.clone(); - async move { - BlobFormatReader::new("data.blob".into(), descriptor) - .read_batch_stream( - Box::new(reader), - data.len() as u64, - &fields, - None, - Some(1024), - None, - ) - .await - .unwrap() - .try_collect::>() - .await - .unwrap() - } - }; - let first = scan(true).await; - let reads = delegate.ranges.lock().unwrap().clone(); - assert!(!reads.is_empty()); - for needle in [b"hello", b"world"] { - for (offset, _) in data - .windows(needle.len()) - .enumerate() - .filter(|(_, bytes)| *bytes == needle) - { - assert!(reads.iter().all(|range| range.end <= offset as u64 - || range.start >= (offset + needle.len()) as u64)); - } - } - assert_eq!(scan(true).await, first); - assert_eq!(delegate.ranges.lock().unwrap().len(), reads.len()); - let materialized = scan(false).await; - assert_eq!( - materialized - .iter() - .map(|batch| batch.num_rows()) - .sum::(), - first.iter().map(|batch| batch.num_rows()).sum::() - ); - assert!(delegate.ranges.lock().unwrap().len() > reads.len()); - } - } - } - #[derive(Debug)] struct CountingReader { data: Bytes, @@ -633,26 +303,6 @@ mod tests { reads: AtomicUsize, } - #[derive(Debug)] - struct ConcurrentCountingReader { - data: Bytes, - reads: AtomicUsize, - in_flight: AtomicUsize, - max_in_flight: AtomicUsize, - } - - #[async_trait::async_trait] - impl FileRead for ConcurrentCountingReader { - async fn read(&self, range: Range) -> crate::Result { - self.reads.fetch_add(1, Ordering::SeqCst); - let in_flight = self.in_flight.fetch_add(1, Ordering::SeqCst) + 1; - self.max_in_flight.fetch_max(in_flight, Ordering::SeqCst); - tokio::time::sleep(std::time::Duration::from_millis(20)).await; - self.in_flight.fetch_sub(1, Ordering::SeqCst); - Ok(self.data.slice(range.start as usize..range.end as usize)) - } - } - #[async_trait::async_trait] impl FileRead for SlowCountingReader { async fn read(&self, range: Range) -> crate::Result { diff --git a/crates/paimon/src/io/cache/state.rs b/crates/paimon/src/io/cache/state.rs index 4c71bd962..7625186b2 100644 --- a/crates/paimon/src/io/cache/state.rs +++ b/crates/paimon/src/io/cache/state.rs @@ -23,10 +23,8 @@ use std::sync::{Arc, Mutex, Weak}; pub(super) struct BlockKey { pub(super) namespace: String, pub(super) path: String, - // Exact ranges use length/start; aligned blocks use block size/index. pub(super) block_size: u64, pub(super) block_index: u64, - pub(super) exact_range: bool, } impl BlockKey { @@ -46,35 +44,9 @@ impl BlockKey { path: path.into(), block_size, block_index, - exact_range: false, } } - pub(super) fn offset(&self) -> Option { - if self.exact_range { - Some(self.block_index) - } else { - self.block_index.checked_mul(self.block_size) - } - } - - pub(super) fn checked_memory_cost(&self, payload_len: usize) -> Option { - let payload_len = u64::try_from(payload_len).ok()?; - if self.exact_range { - payload_len - .checked_add(u64::try_from(self.namespace.len()).ok()?)? - .checked_add(u64::try_from(self.path.len()).ok()?)? - .checked_add(256) - .map(|cost| cost.max(512)) - } else { - Some(payload_len) - } - } - - pub(super) fn memory_cost(&self, payload_len: usize) -> u64 { - self.checked_memory_cost(payload_len).unwrap_or(u64::MAX) - } - pub(super) fn matches_path(&self, namespace: &str, path: &str) -> bool { self.namespace == namespace && self.path == path } diff --git a/crates/paimon/src/io/file_io.rs b/crates/paimon/src/io/file_io.rs index 5db8e1086..d42c988f6 100644 --- a/crates/paimon/src/io/file_io.rs +++ b/crates/paimon/src/io/file_io.rs @@ -107,7 +107,7 @@ impl FileIO { /// `block_size` controls the aligned ranges presented to the cache. /// `whitelist` uses the same comma-separated values as /// `local-cache.whitelist`: `meta`, `global-index`, `bucket-index`, `data`, - /// `file-index`, and `blob-meta` (exact BLOB metadata ranges). + /// and `file-index`. pub fn with_file_block_cache( mut self, cache: Arc, @@ -688,11 +688,6 @@ impl FileIOBuilder { #[async_trait::async_trait] pub trait FileRead: Send + Sync + Unpin + 'static { - /// Read BLOB metadata, allowing caches to retain only the requested range. - async fn read_blob_metadata(&self, range: Range) -> crate::Result { - self.read(range).await - } - async fn read(&self, range: Range) -> crate::Result; } @@ -710,13 +705,6 @@ enum InputFileReader { #[async_trait::async_trait] impl FileRead for InputFileReader { - async fn read_blob_metadata(&self, range: Range) -> crate::Result { - match self { - Self::Direct(reader) => reader.read_blob_metadata(range).await, - Self::Cached(reader) => reader.read_blob_metadata(range).await, - } - } - async fn read(&self, range: Range) -> crate::Result { match self { Self::Direct(reader) => FileRead::read(reader, range).await, @@ -891,9 +879,6 @@ impl InputFile { let Some(cache) = &self.cache else { return Ok(op.read(&relative_path).await?.to_bytes()); }; - if !cache.cache_blocks(&self.path) { - return Ok(op.read(&relative_path).await?.to_bytes()); - } let read_token = cache.read_token(&cache_path); let size = if let Some(size) = cache.file_size(&cache_path, &read_token).await { size @@ -903,51 +888,24 @@ impl InputFile { size }; let delegate = Arc::new(op.reader(&relative_path).await?); - CachedFileReader::new_with_token( - delegate, - &cache_path, - Some(size), - cache.clone(), - read_token, - ) - .read_full() - .await + CachedFileReader::new_with_token(delegate, &cache_path, size, cache.clone(), read_token) + .read_full() + .await } pub async fn reader(&self) -> crate::Result { - self.reader_with_known_size(None).await - } - - pub(crate) async fn reader_with_file_size( - &self, - file_size: u64, - ) -> crate::Result { - self.reader_with_known_size(Some(file_size)).await - } - - async fn reader_with_known_size( - &self, - known_size: Option, - ) -> crate::Result { 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 { return Ok(InputFileReader::Direct(reader)); }; let read_token = cache.read_token(&cache_path); - // Classify the logical path, never the opaque cache identity. Exact metadata - // ranges do not need the file size; body reads must not trigger a stat. - let size = if !cache.cache_blocks(&self.path) { - None - } else if let Some(size) = known_size { - cache.put_file_size(&cache_path, size, &read_token).await; - Some(size) - } else if let Some(size) = cache.file_size(&cache_path, &read_token).await { - Some(size) + 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; - Some(size) + size }; Ok(InputFileReader::Cached(CachedFileReader::new_with_token( Arc::new(reader), @@ -1668,105 +1626,66 @@ mod input_output_test { } #[tokio::test] - async fn test_blob_metadata_default_file_io_cache_does_not_cache_body() { - let source = tempfile::tempdir().unwrap(); - let cache_dir = tempfile::tempdir().unwrap(); - let path = source.path().join("data.blob"); - std::fs::write(&path, b"metabody").unwrap(); - let location = path.to_str().unwrap(); - let file_io = setup_cached_fs_file_io(cache_dir.path()); - let input = file_io.new_input(location).unwrap(); - let reader = input.reader().await.unwrap(); - assert_eq!(reader.read_blob_metadata(0..4).await.unwrap(), b"meta"[..]); - assert_eq!(reader.read(4..8).await.unwrap(), b"body"[..]); - drop(reader); - std::fs::remove_file(&path).unwrap(); - let reader = input.reader().await.unwrap(); - assert_eq!(reader.read_blob_metadata(0..4).await.unwrap(), b"meta"[..]); - assert!(reader.read(4..8).await.is_err()); - assert!(input.read().await.is_err()); - } - - #[tokio::test] - async fn test_blob_metadata_reader_needs_no_stat() { - let source = tempfile::tempdir().unwrap(); - let cache_dir = tempfile::tempdir().unwrap(); - let path = source.path().join("data.blob"); - let input = setup_cached_fs_file_io(cache_dir.path()) - .new_input(path.to_str().unwrap()) - .unwrap(); - // Opening the reader must succeed even when a source stat would fail. - assert!(input.metadata().await.is_err()); - let reader = input.reader().await.unwrap(); - std::fs::write(&path, b"metabody").unwrap(); - assert_eq!(reader.read(4..8).await.unwrap(), b"body"[..]); - assert_eq!(reader.read_blob_metadata(0..4).await.unwrap(), b"meta"[..]); - drop(reader); - std::fs::remove_file(&path).unwrap(); - let reader = input.reader().await.unwrap(); - assert_eq!(reader.read_blob_metadata(0..4).await.unwrap(), b"meta"[..]); - assert!(reader.read(4..8).await.is_err()); - } - - #[tokio::test] - async fn test_root_metadata_range_cache_uses_logical_path() { + async fn test_parquet_only_cache_and_data_compatibility() { for disk in [false, true] { - let directory = tempfile::tempdir().unwrap(); - let mut options = Options::new(); - options.set(CatalogOptions::LOCAL_CACHE_ENABLED, "true"); - if disk { - options.set( - CatalogOptions::LOCAL_CACHE_DIR, - directory.path().to_string_lossy(), + for whitelist in ["meta,global-index", "parquet-data", "data"] { + let directory = tempfile::tempdir().unwrap(); + let mut options = Options::new(); + options.set(CatalogOptions::LOCAL_CACHE_ENABLED, "true"); + options.set(CatalogOptions::LOCAL_CACHE_WHITELIST, whitelist); + options.set(CatalogOptions::LOCAL_CACHE_BLOCK_SIZE, "4"); + if disk { + options.set( + CatalogOptions::LOCAL_CACHE_DIR, + directory.path().to_string_lossy(), + ); + } + let cache = Arc::new( + LocalCache::new(LocalCacheConfig::from_options(&options).unwrap().unwrap()) + .unwrap(), ); - } - options.set(CatalogOptions::LOCAL_CACHE_BLOCK_SIZE, "4"); - let cache = Arc::new( - LocalCache::new(LocalCacheConfig::from_options(&options).unwrap().unwrap()) - .unwrap(), - ); - let file_io = FileIOBuilder::new("memory") - .with_local_cache(cache) - .build() - .unwrap(); - for path in [ - "memory:/snapshot-1", - "memory:/schema-1", - "memory:/snapshot/snapshot-1", - ] { - file_io - .new_output(path) - .unwrap() - .write(Bytes::from_static(b"metadata")) - .await + let file_io = FileIOBuilder::new("memory") + .with_local_cache(cache) + .build() .unwrap(); - let input = file_io.new_input(path).unwrap(); - let reader = input.reader().await.unwrap(); - assert_eq!(reader.read(1..7).await.unwrap(), b"etadat"[..]); - // Delete the source directly, without invalidating the cache. - let (op, relative_path, _) = input.source.resolve(path).await.unwrap(); - op.delete(&relative_path).await.unwrap(); - let reader = input.reader().await.unwrap(); - assert_eq!(reader.read(1..7).await.unwrap(), b"etadat"[..], "{path}"); + for name in [ + "data.parquet", + "data.blob", + "data.orc", + "data.parquet.index", + "snapshot-1", + ] { + let path = format!("memory:/{name}"); + file_io + .new_output(&path) + .unwrap() + .write(Bytes::from_static(b"abcdefgh")) + .await + .unwrap(); + let input = file_io.new_input(&path).unwrap(); + let reader = input.reader().await.unwrap(); + assert_eq!(reader.read(1..7).await.unwrap(), b"bcdefg"[..]); + drop(reader); + let (op, relative_path, _) = input.source.resolve(&path).await.unwrap(); + op.delete(&relative_path).await.unwrap(); + let cached = match whitelist { + "parquet-data" => name.ends_with(".parquet"), + "data" => name.starts_with("data.") && !name.ends_with(".index"), + _ => name == "snapshot-1", + }; + let result = input.read().await; + if cached { + assert_eq!(result.unwrap(), b"abcdefgh"[..], "{whitelist}: {name}"); + let reader = input.reader().await.unwrap(); + assert_eq!(reader.read(1..7).await.unwrap(), b"bcdefg"[..]); + } else { + assert!(result.is_err(), "{whitelist}: {name}"); + } + } } } } - #[tokio::test] - async fn test_reader_with_file_size_skips_source_stat() { - let source = tempfile::tempdir().unwrap(); - let cache_dir = tempfile::tempdir().unwrap(); - let path = source.path().join("snapshot-1"); - // Use a logical path so metadata classification is identical on Windows. - let location = path.to_string_lossy().replace('\\', "/"); - let input = setup_cached_fs_file_io(cache_dir.path()) - .new_input(&location) - .unwrap(); - - assert!(input.reader().await.is_err()); - assert!(input.reader_with_file_size(42).await.is_ok()); - } - 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(); diff --git a/crates/paimon/src/table/data_file_reader.rs b/crates/paimon/src/table/data_file_reader.rs index eed05d863..16d6bfa15 100644 --- a/crates/paimon/src/table/data_file_reader.rs +++ b/crates/paimon/src/table/data_file_reader.rs @@ -101,13 +101,6 @@ struct TimedFileRead { #[async_trait::async_trait] impl FileRead for TimedFileRead { - async fn read_blob_metadata(&self, range: Range) -> crate::Result { - let start = Instant::now(); - let result = self.inner.read_blob_metadata(range).await; - self.timing.add_file_read(start.elapsed()); - result - } - async fn read(&self, range: Range) -> crate::Result { let start = Instant::now(); let result = self.inner.read(range).await; @@ -612,9 +605,7 @@ impl DataFileReader { let schema_open_start = read_timing.as_ref().map(|_| Instant::now()); let input_file = file_io.new_input(&path_to_read)?; let open_start = read_timing.as_ref().map(|_| Instant::now()); - let file_reader = input_file - .reader_with_file_size(file_meta.file_size as u64) - .await?; + let file_reader = input_file.reader().await?; if let (Some(timing), Some(start)) = (read_timing.as_ref(), open_start) { timing.add_file_read(start.elapsed()); } @@ -912,9 +903,7 @@ impl DataFileReader { Ok(try_stream! { let input_file = file_io.new_input(&path_to_read)?; - let file_reader = input_file - .reader_with_file_size(file_meta.file_size as u64) - .await?; + let file_reader = input_file.reader().await?; let mut batch_stream = format_reader .read_batch_stream( diff --git a/docs/src/getting-started.md b/docs/src/getting-started.md index 9f188d126..a8777c038 100644 --- a/docs/src/getting-started.md +++ b/docs/src/getting-started.md @@ -313,7 +313,7 @@ options.set(CatalogOptions::LOCAL_CACHE_MAX_SIZE, "20 GiB"); options.set(CatalogOptions::LOCAL_CACHE_BLOCK_SIZE, "1 MiB"); options.set( CatalogOptions::LOCAL_CACHE_WHITELIST, - "meta,global-index,blob-meta", + "meta,global-index", ); let catalog = CatalogFactory::create(options).await?; ``` @@ -322,13 +322,11 @@ 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 | Cache budget, including BLOB metadata entry overhead. Accepts units such as `512 MiB` or `20 GiB`. | +| `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.block-size` | `1 MiB` | Block size used for cached range reads. | -| `local-cache.whitelist` | `meta,global-index,blob-meta` | Cache categories: `meta`, `global-index`, `bucket-index`, `data`, `file-index`, and `blob-meta`. | +| `local-cache.whitelist` | `meta,global-index` | Comma-separated eligible types: `meta`, `global-index`, `bucket-index`, `data`, `parquet-data`, and `file-index`. | -`blob-meta` caches BLOB footer/index and ARRAY/MAP metadata bytes, including MAP keys, -without value bodies. Entries share the cache budget, including overhead. Built-in caches -hold at most 65,536 entries; metadata entries exceeding the budget or 64 MiB are not cached. +Use `meta,global-index,parquet-data` to cache Parquet without BLOB bodies. `data` still includes all data formats. 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 From 58648a7624b4b19f23d0562920fc9763d59e366a Mon Sep 17 00:00:00 2001 From: xiaohongbo Date: Mon, 28 Sep 2026 02:23:07 -0700 Subject: [PATCH 11/12] feat(core): exclude file extensions from local caching --- crates/paimon/src/common/options.rs | 3 + crates/paimon/src/io/cache/file_type.rs | 12 +-- crates/paimon/src/io/cache/mod.rs | 29 ++++++- crates/paimon/src/io/cache/reader.rs | 10 +++ crates/paimon/src/io/file_io.rs | 109 +++++++++++++----------- docs/src/getting-started.md | 5 +- 6 files changed, 105 insertions(+), 63 deletions(-) diff --git a/crates/paimon/src/common/options.rs b/crates/paimon/src/common/options.rs index c23cd9318..d482b6446 100644 --- a/crates/paimon/src/common/options.rs +++ b/crates/paimon/src/common/options.rs @@ -87,6 +87,9 @@ impl CatalogOptions { /// Comma-separated file types eligible for local caching. pub const LOCAL_CACHE_WHITELIST: &'static str = "local-cache.whitelist"; + + /// Comma-separated file extensions that bypass the local cache. + pub const LOCAL_CACHE_EXCLUDE_EXTENSIONS: &'static str = "local-cache.exclude-extensions"; } /// Configuration options container. diff --git a/crates/paimon/src/io/cache/file_type.rs b/crates/paimon/src/io/cache/file_type.rs index 082926fab..2db0ae5a7 100644 --- a/crates/paimon/src/io/cache/file_type.rs +++ b/crates/paimon/src/io/cache/file_type.rs @@ -21,7 +21,6 @@ use std::collections::HashSet; pub(super) enum FileType { Meta, Data, - ParquetData, BucketIndex, GlobalIndex, FileIndex, @@ -54,11 +53,7 @@ impl FileType { return Self::BucketIndex; } - if name.ends_with(".parquet") { - Self::ParquetData - } else { - Self::Data - } + Self::Data } pub(super) fn is_mutable(path: &str) -> bool { @@ -80,13 +75,12 @@ impl FileType { "global-index" => Some(Self::GlobalIndex), "bucket-index" => Some(Self::BucketIndex), "data" => Some(Self::Data), - "parquet-data" => Some(Self::ParquetData), "file-index" => Some(Self::FileIndex), "" => None, unknown => { log::warn!( "Unknown local-cache.whitelist value '{}'; supported values are \ - meta, global-index, bucket-index, data, parquet-data, file-index", + meta, global-index, bucket-index, data, file-index", unknown ); None @@ -125,7 +119,7 @@ mod tests { ), ( "s3://bucket/table/bucket-0/data-abc.parquet", - FileType::ParquetData, + FileType::Data, ), ]; diff --git a/crates/paimon/src/io/cache/mod.rs b/crates/paimon/src/io/cache/mod.rs index f5447d9c6..5efceceb0 100644 --- a/crates/paimon/src/io/cache/mod.rs +++ b/crates/paimon/src/io/cache/mod.rs @@ -44,6 +44,7 @@ pub(crate) struct LocalCache { namespace: String, block_size: u64, whitelist: HashSet, + excluded_extensions: HashSet, file_size_capacity: usize, } @@ -71,6 +72,7 @@ impl LocalCache { namespace: String::new(), block_size, whitelist: FileType::parse_whitelist(whitelist), + excluded_extensions: HashSet::new(), file_size_capacity: DEFAULT_FILE_SIZE_CAPACITY, }) } @@ -98,6 +100,7 @@ impl LocalCache { namespace: config.namespace, block_size: config.block_size, whitelist: config.whitelist, + excluded_extensions: config.excluded_extensions, file_size_capacity, }) } @@ -111,10 +114,15 @@ impl LocalCache { } pub(super) fn is_cacheable(&self, path: &str) -> bool { - let file_type = FileType::classify(path); + let extension = path + .rsplit('/') + .next() + .and_then(|name| name.rsplit_once('.')); !FileType::is_mutable(path) - && (self.whitelist.contains(&file_type) - || (file_type == FileType::ParquetData && self.whitelist.contains(&FileType::Data))) + && !extension.is_some_and(|(_, ext)| { + self.excluded_extensions.contains(&ext.to_ascii_lowercase()) + }) + && self.whitelist.contains(&FileType::classify(path)) } async fn get_block( @@ -250,6 +258,7 @@ pub(crate) struct LocalCacheConfig { max_size: Option, block_size: u64, whitelist: HashSet, + excluded_extensions: HashSet, } impl LocalCacheConfig { @@ -316,6 +325,14 @@ impl LocalCacheConfig { max_size, block_size, whitelist: FileType::parse_whitelist(whitelist), + excluded_extensions: options + .get(CatalogOptions::LOCAL_CACHE_EXCLUDE_EXTENSIONS) + .map(String::as_str) + .unwrap_or("") + .split(',') + .map(|value| value.trim().trim_start_matches('.').to_ascii_lowercase()) + .filter(|value| !value.is_empty()) + .collect(), })) } } @@ -476,6 +493,7 @@ mod tests { max_size: None, block_size: 4, whitelist: HashSet::from([FileType::Meta]), + excluded_extensions: HashSet::new(), }) .unwrap(); let path = "s3://bucket/table/snapshot/snapshot-1"; @@ -497,6 +515,7 @@ mod tests { max_size: None, block_size: 4, whitelist: HashSet::from([FileType::Meta]), + excluded_extensions: HashSet::new(), }; let first = LocalCache::new(config()).unwrap(); let second = LocalCache::new(config()).unwrap(); @@ -519,6 +538,7 @@ mod tests { max_size: None, block_size: 4, whitelist: HashSet::from([FileType::Meta]), + excluded_extensions: HashSet::new(), }; let first = LocalCache::new(config()).unwrap(); let second = LocalCache::new(config()).unwrap(); @@ -542,6 +562,7 @@ mod tests { max_size: None, block_size: 4, whitelist: HashSet::from([FileType::Meta]), + excluded_extensions: HashSet::new(), }) .unwrap(); @@ -569,6 +590,7 @@ mod tests { max_size: None, block_size: 4, whitelist: HashSet::from([FileType::Meta]), + excluded_extensions: HashSet::new(), }) .unwrap(); @@ -589,6 +611,7 @@ mod tests { max_size: Some(8), block_size: 4, whitelist: HashSet::from([FileType::Meta]), + excluded_extensions: HashSet::new(), }) .unwrap(); let first_token = cache.read_token("snapshot-1"); diff --git a/crates/paimon/src/io/cache/reader.rs b/crates/paimon/src/io/cache/reader.rs index 240b163d1..6ea1cfb00 100644 --- a/crates/paimon/src/io/cache/reader.rs +++ b/crates/paimon/src/io/cache/reader.rs @@ -275,6 +275,7 @@ mod tests { max_size: None, block_size: 4, whitelist: std::collections::HashSet::from([FileType::Meta]), + excluded_extensions: std::collections::HashSet::new(), }) .unwrap(), ); @@ -325,6 +326,7 @@ mod tests { max_size: None, block_size: 4, whitelist: std::collections::HashSet::from([FileType::Meta]), + excluded_extensions: std::collections::HashSet::new(), }) .unwrap(), ); @@ -355,6 +357,7 @@ mod tests { max_size: None, block_size: 4, whitelist: std::collections::HashSet::from([FileType::Meta]), + excluded_extensions: std::collections::HashSet::new(), }; let first_reader = CachedFileReader::new( delegate.clone(), @@ -402,6 +405,7 @@ mod tests { max_size: None, block_size: 4, whitelist: std::collections::HashSet::from([FileType::Meta]), + excluded_extensions: std::collections::HashSet::new(), }) .unwrap(), ); @@ -451,6 +455,7 @@ mod tests { max_size: None, block_size: 4, whitelist: std::collections::HashSet::from([FileType::Meta]), + excluded_extensions: std::collections::HashSet::new(), }) .unwrap(), ); @@ -461,6 +466,7 @@ mod tests { max_size: None, block_size: 4, whitelist: std::collections::HashSet::from([FileType::Meta]), + excluded_extensions: std::collections::HashSet::new(), }) .unwrap(), ); @@ -512,6 +518,7 @@ mod tests { max_size: None, block_size: 4, whitelist: std::collections::HashSet::from([FileType::Meta]), + excluded_extensions: std::collections::HashSet::new(), }; let cache_a = Arc::new(LocalCache::new(config()).unwrap()); let cache_b = Arc::new(LocalCache::new(config()).unwrap()); @@ -658,6 +665,7 @@ mod tests { max_size: None, block_size: 4, whitelist: std::collections::HashSet::from([FileType::Meta]), + excluded_extensions: std::collections::HashSet::new(), }) .unwrap(), ); @@ -692,6 +700,7 @@ mod tests { max_size: None, block_size: 4, whitelist: std::collections::HashSet::from([FileType::Meta]), + excluded_extensions: std::collections::HashSet::new(), }) .unwrap(), ); @@ -713,6 +722,7 @@ mod tests { max_size: None, block_size: 4, whitelist: std::collections::HashSet::from([FileType::Meta]), + excluded_extensions: std::collections::HashSet::new(), }) .unwrap(), ); diff --git a/crates/paimon/src/io/file_io.rs b/crates/paimon/src/io/file_io.rs index d42c988f6..f844988e8 100644 --- a/crates/paimon/src/io/file_io.rs +++ b/crates/paimon/src/io/file_io.rs @@ -1626,60 +1626,71 @@ mod input_output_test { } #[tokio::test] - async fn test_parquet_only_cache_and_data_compatibility() { + async fn test_data_cache_excludes_blob_extension() { for disk in [false, true] { - for whitelist in ["meta,global-index", "parquet-data", "data"] { - let directory = tempfile::tempdir().unwrap(); - let mut options = Options::new(); - options.set(CatalogOptions::LOCAL_CACHE_ENABLED, "true"); - options.set(CatalogOptions::LOCAL_CACHE_WHITELIST, whitelist); - options.set(CatalogOptions::LOCAL_CACHE_BLOCK_SIZE, "4"); - if disk { - options.set( - CatalogOptions::LOCAL_CACHE_DIR, - directory.path().to_string_lossy(), + for whitelist in ["meta,global-index", "data"] { + for exclusion in ["", " .BLOB, "] { + let directory = tempfile::tempdir().unwrap(); + let mut options = Options::new(); + options.set(CatalogOptions::LOCAL_CACHE_ENABLED, "true"); + options.set(CatalogOptions::LOCAL_CACHE_WHITELIST, whitelist); + options.set(CatalogOptions::LOCAL_CACHE_EXCLUDE_EXTENSIONS, exclusion); + options.set(CatalogOptions::LOCAL_CACHE_BLOCK_SIZE, "4"); + if disk { + options.set( + CatalogOptions::LOCAL_CACHE_DIR, + directory.path().to_string_lossy(), + ); + } + let cache = Arc::new( + LocalCache::new(LocalCacheConfig::from_options(&options).unwrap().unwrap()) + .unwrap(), ); - } - let cache = Arc::new( - LocalCache::new(LocalCacheConfig::from_options(&options).unwrap().unwrap()) - .unwrap(), - ); - let file_io = FileIOBuilder::new("memory") - .with_local_cache(cache) - .build() - .unwrap(); - for name in [ - "data.parquet", - "data.blob", - "data.orc", - "data.parquet.index", - "snapshot-1", - ] { - let path = format!("memory:/{name}"); - file_io - .new_output(&path) - .unwrap() - .write(Bytes::from_static(b"abcdefgh")) - .await + let file_io = FileIOBuilder::new("memory") + .with_local_cache(cache) + .build() .unwrap(); - let input = file_io.new_input(&path).unwrap(); - let reader = input.reader().await.unwrap(); - assert_eq!(reader.read(1..7).await.unwrap(), b"bcdefg"[..]); - drop(reader); - let (op, relative_path, _) = input.source.resolve(&path).await.unwrap(); - op.delete(&relative_path).await.unwrap(); - let cached = match whitelist { - "parquet-data" => name.ends_with(".parquet"), - "data" => name.starts_with("data.") && !name.ends_with(".index"), - _ => name == "snapshot-1", - }; - let result = input.read().await; - if cached { - assert_eq!(result.unwrap(), b"abcdefgh"[..], "{whitelist}: {name}"); + for name in [ + "data.parquet", + "data.blob", + "data.orc", + "data.avro", + "data.parquet.index", + "snapshot-1", + "snapshot-1.blob", + ] { + let path = format!("memory:/{name}"); + file_io + .new_output(&path) + .unwrap() + .write(Bytes::from_static(b"abcdefgh")) + .await + .unwrap(); + let input = file_io.new_input(&path).unwrap(); let reader = input.reader().await.unwrap(); assert_eq!(reader.read(1..7).await.unwrap(), b"bcdefg"[..]); - } else { - assert!(result.is_err(), "{whitelist}: {name}"); + drop(reader); + let (op, relative_path, _) = input.source.resolve(&path).await.unwrap(); + op.delete(&relative_path).await.unwrap(); + let cached = match whitelist { + "data" => { + name.starts_with("data.") + && !(name.ends_with(".blob") && !exclusion.is_empty()) + && !name.ends_with(".index") + } + _ => { + name == "snapshot-1" + || (name == "snapshot-1.blob" && exclusion.is_empty()) + } + }; + let result = input.read().await; + if cached { + assert_eq!(result.unwrap(), b"abcdefgh"[..], "{whitelist}: {name}"); + let reader = input.reader().await.unwrap(); + assert_eq!(reader.read(1..7).await.unwrap(), b"bcdefg"[..]); + } else { + assert!(result.is_err(), "{whitelist}: {name}"); + } } } } diff --git a/docs/src/getting-started.md b/docs/src/getting-started.md index a8777c038..68a1c1ae2 100644 --- a/docs/src/getting-started.md +++ b/docs/src/getting-started.md @@ -324,9 +324,10 @@ let catalog = CatalogFactory::create(options).await?; | `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.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`, `parquet-data`, and `file-index`. | +| `local-cache.whitelist` | `meta,global-index` | Comma-separated eligible types: `meta`, `global-index`, `bucket-index`, `data`, and `file-index`. | +| `local-cache.exclude-extensions` | empty | Comma-separated file extensions to bypass, even when whitelisted. | -Use `meta,global-index,parquet-data` to cache Parquet without BLOB bodies. `data` still includes all data formats. +Use `meta,global-index,data` with `local-cache.exclude-extensions=blob` to cache data files except BLOB bodies. 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 From 79a6d16e6607b5a1fd64b69c89a1e30028614e79 Mon Sep 17 00:00:00 2001 From: xiaohongbo Date: Mon, 28 Sep 2026 02:32:20 -0700 Subject: [PATCH 12/12] fix(core): apply cache exclusions to file extensions only --- crates/paimon/src/io/cache/mod.rs | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/crates/paimon/src/io/cache/mod.rs b/crates/paimon/src/io/cache/mod.rs index 5efceceb0..3561686a1 100644 --- a/crates/paimon/src/io/cache/mod.rs +++ b/crates/paimon/src/io/cache/mod.rs @@ -117,7 +117,8 @@ impl LocalCache { let extension = path .rsplit('/') .next() - .and_then(|name| name.rsplit_once('.')); + .and_then(|name| name.rsplit_once('.')) + .filter(|(stem, _)| !stem.is_empty()); !FileType::is_mutable(path) && !extension.is_some_and(|(_, ext)| { self.excluded_extensions.contains(&ext.to_ascii_lowercase()) @@ -562,11 +563,12 @@ mod tests { max_size: None, block_size: 4, whitelist: HashSet::from([FileType::Meta]), - excluded_extensions: HashSet::new(), + excluded_extensions: HashSet::from(["blob".to_string(), "snapshot-1".to_string()]), }) .unwrap(); assert!(cache.is_cacheable("s3://bucket/table/snapshot/snapshot-1")); + assert!(!cache.is_cacheable("s3://bucket/table/snapshot/snapshot-1.blob")); assert!(!cache.is_cacheable("s3://bucket/table/data/data-1.parquet")); assert!(!cache.is_cacheable("s3://bucket/table/snapshot/LATEST")); assert!(!cache.is_cacheable("s3://bucket/table/tag/tag-production"));