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

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


The following commit(s) were added to refs/heads/main by this push:
     new 1f1a47c0 [core] Coalesce consecutive local cache misses (#1218)
1f1a47c0 is described below

commit 1f1a47c0101b3629188a72d0e7207fe7d2cc80a3
Author: XiaoHongbo <[email protected]>
AuthorDate: Sun Oct 25 23:09:18 2026 +0800

    [core] Coalesce consecutive local cache misses (#1218)
---
 crates/paimon/src/io/cache/mod.rs    |   7 +
 crates/paimon/src/io/cache/reader.rs | 410 ++++++++++++++++++++++++++++++++++-
 crates/paimon/src/io/cache/shared.rs |  52 ++++-
 3 files changed, 459 insertions(+), 10 deletions(-)

diff --git a/crates/paimon/src/io/cache/mod.rs 
b/crates/paimon/src/io/cache/mod.rs
index 5891f283..57f15967 100644
--- a/crates/paimon/src/io/cache/mod.rs
+++ b/crates/paimon/src/io/cache/mod.rs
@@ -236,6 +236,13 @@ impl LocalCache {
         }
     }
 
+    async fn shared_load_locks(&self, keys: &[BlockKey]) -> Vec<std::fs::File> 
{
+        match &self.backend {
+            CacheBackend::SharedDisk(disk) => disk.load_locks(keys).await,
+            _ => Vec::new(),
+        }
+    }
+
     async fn block_load_lock(&self, key: &BlockKey) -> 
Arc<tokio::sync::Mutex<()>> {
         self.coordinator.block_load_lock(key).await
     }
diff --git a/crates/paimon/src/io/cache/reader.rs 
b/crates/paimon/src/io/cache/reader.rs
index 35781cc5..d60099f7 100644
--- a/crates/paimon/src/io/cache/reader.rs
+++ b/crates/paimon/src/io/cache/reader.rs
@@ -145,6 +145,114 @@ impl CachedFileReader {
         result
     }
 
+    async fn read_blocks(&self, first: u64, last: u64) -> 
crate::Result<Vec<Bytes>> {
+        if first == last {
+            return Ok(vec![self.read_block(first).await?]);
+        }
+        let size = self.cache.block_size();
+        let mut blocks = Vec::new();
+        let mut locks = Vec::new();
+        // Acquire missing-block locks in offset order, including overlapping 
reads.
+        for index in first..=last {
+            let key = self.cache.block_key(&self.path, index);
+            let len = (self.file_size - index * size).min(size) as usize;
+            let mut block = self.cache.get_block(&key, len, 
&self.read_token).await;
+            if block.as_ref().is_some_and(|b| b.len() != len) {
+                self.cache.remove_block(&key).await;
+                block = None;
+            }
+            if block.is_none() {
+                let lock = self.cache.block_load_lock(&key).await;
+                let guard = lock.clone().lock_owned().await;
+                block = self.cache.get_block(&key, len, 
&self.read_token).await;
+                if block.as_ref().is_some_and(|b| b.len() != len) {
+                    self.cache.remove_block(&key).await;
+                    block = None;
+                }
+                locks.push(Some((key, lock, guard)));
+            } else {
+                locks.push(None);
+            }
+            blocks.push(block);
+        }
+        // Try shared stripes after local locks; collisions must not stall a 
batch.
+        let keys: Vec<_> = locks
+            .iter()
+            .flatten()
+            .map(|(key, _, _)| key.clone())
+            .collect();
+        let shared_guards = 
Arc::new(self.cache.shared_load_locks(&keys).await);
+        for (index, entry) in locks.iter().enumerate() {
+            if let Some((key, _, _)) = entry {
+                let len = (self.file_size - (first + index as u64) * 
size).min(size) as usize;
+                let block = self.cache.get_block(key, len, 
&self.read_token).await;
+                if block.as_ref().is_some_and(|b| b.len() != len) {
+                    self.cache.remove_block(key).await;
+                    blocks[index] = None;
+                } else {
+                    blocks[index] = block;
+                }
+            }
+        }
+        let result = async {
+            let mut i = 0;
+            while i < blocks.len() {
+                if blocks[i].is_some() {
+                    i += 1;
+                    continue;
+                }
+                let begin = i;
+                while i < blocks.len() && blocks[i].is_none() {
+                    i += 1;
+                }
+                let start = (first + begin as u64) * size;
+                let end = (first + i as 
u64).saturating_mul(size).min(self.file_size);
+                let payload = self.delegate.read(start..end).await?;
+                if payload.len() as u64 != end - start {
+                    return Err(crate::Error::DataInvalid {
+                        message: "Truncated cache range read".to_string(),
+                        source: None,
+                    });
+                }
+                for (offset, slot) in blocks[begin..i].iter_mut().enumerate() {
+                    let lo = offset * size as usize;
+                    let hi = (lo + size as usize).min(payload.len());
+                    // Each cached block owns only its bytes, not the entire 
fetched range.
+                    let block = Bytes::copy_from_slice(&payload[lo..hi]);
+                    let key = self
+                        .cache
+                        .block_key(&self.path, first + (begin + offset) as 
u64);
+                    if let Some(fill_slot) = self.cache.background_fill_slot() 
{
+                        let (_, lock, guard) = locks[begin + 
offset].take().unwrap();
+                        let shared_guards = shared_guards.clone();
+                        let cache = self.cache.clone();
+                        let token = self.read_token.clone();
+                        let fill_block = block.clone();
+                        tokio::spawn(async move {
+                            let _slot = fill_slot;
+                            let _shared_guards = shared_guards;
+                            cache.put_block(&key, fill_block, &token).await;
+                            drop(guard);
+                            cache.release_block_load_lock(&key, &lock).await;
+                        });
+                    } else {
+                        self.cache
+                            .put_block(&key, block.clone(), &self.read_token)
+                            .await;
+                    }
+                    *slot = Some(block);
+                }
+            }
+            Ok(blocks.into_iter().map(Option::unwrap).collect())
+        }
+        .await;
+        for (key, lock, guard) in locks.into_iter().flatten() {
+            drop(guard);
+            self.cache.release_block_load_lock(&key, &lock).await;
+        }
+        result
+    }
+
     pub(crate) async fn read_full(&self) -> crate::Result<Bytes> {
         if self.file_size == 0 {
             return Ok(Bytes::new());
@@ -244,12 +352,18 @@ 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_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;
-            output.extend_from_slice(&block[copy_start as usize..copy_end as 
usize]);
+        // Bound both temporary bytes and the number of held block locks.
+        let batch_size = (8 * 1024 * 1024 / block_size).clamp(1, 32);
+        let mut first = first_block;
+        while first <= last_block {
+            let last = first.saturating_add(batch_size - 1).min(last_block);
+            for (offset, block) in self.read_blocks(first, 
last).await?.into_iter().enumerate() {
+                let block_start = (first + offset as u64) * 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;
+                output.extend_from_slice(&block[copy_start as usize..copy_end 
as usize]);
+            }
+            first = last + 1;
         }
 
         Ok(output.freeze())
@@ -271,6 +385,167 @@ mod tests {
         reads: AtomicUsize,
     }
 
+    fn test_cache() -> Arc<LocalCache> {
+        Arc::new(
+            LocalCache::new(LocalCacheConfig {
+                shared: false,
+                dir: None,
+                namespace: "test".into(),
+                max_size: None,
+                block_size: 4,
+                whitelist: std::collections::HashSet::from([FileType::Meta]),
+            })
+            .unwrap(),
+        )
+    }
+
+    #[tokio::test]
+    async fn test_coalesced_misses_preserve_warm_holes_and_partial_tail() {
+        let delegate = Arc::new(CountingReader {
+            data: Bytes::from_static(b"abcdefghijklmn"),
+            reads: AtomicUsize::new(0),
+        });
+        let reader = CachedFileReader::new(delegate.clone(), "snapshot-1", 14, 
test_cache());
+        assert_eq!(
+            reader.read(4..8).await.unwrap(),
+            Bytes::from_static(b"efgh")
+        );
+        assert_eq!(
+            reader.read(1..14).await.unwrap(),
+            Bytes::from_static(b"bcdefghijklmn")
+        );
+        assert_eq!(delegate.reads.load(Ordering::SeqCst), 3);
+        reader.read(0..14).await.unwrap();
+        assert_eq!(delegate.reads.load(Ordering::SeqCst), 3);
+    }
+
+    #[tokio::test]
+    async fn test_coalesced_misses_bound_batch_size() {
+        let delegate = Arc::new(CountingReader {
+            data: Bytes::from(vec![42; 160]),
+            reads: AtomicUsize::new(0),
+        });
+        let reader = CachedFileReader::new(delegate.clone(), "snapshot-1", 
160, test_cache());
+        assert_eq!(reader.read(0..160).await.unwrap(), delegate.data);
+        assert_eq!(delegate.reads.load(Ordering::SeqCst), 2);
+    }
+
+    #[tokio::test]
+    async fn test_coalesced_overlapping_reads_share_missing_blocks() {
+        let delegate = Arc::new(SlowCountingReader {
+            data: Bytes::from_static(b"abcdefghijkl"),
+            reads: AtomicUsize::new(0),
+        });
+        let reader = CachedFileReader::new(delegate.clone(), "snapshot-1", 12, 
test_cache());
+        let (a, b) = tokio::time::timeout(std::time::Duration::from_secs(2), 
async {
+            tokio::join!(reader.read(0..12), reader.read(4..12))
+        })
+        .await
+        .unwrap();
+        assert_eq!(a.unwrap(), Bytes::from_static(b"abcdefghijkl"));
+        assert_eq!(b.unwrap(), Bytes::from_static(b"efghijkl"));
+        assert_eq!(delegate.reads.load(Ordering::SeqCst), 1);
+    }
+
+    #[tokio::test]
+    async fn test_coalesced_truncated_read_releases_locks() {
+        let reader = CachedFileReader::new(Arc::new(ShortReader), 
"snapshot-1", 12, test_cache());
+        for _ in 0..2 {
+            assert!(
+                tokio::time::timeout(std::time::Duration::from_secs(2), 
reader.read(0..12))
+                    .await
+                    .unwrap()
+                    .is_err()
+            );
+        }
+    }
+
+    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
+    async fn coalesced_shared_stripe_collisions_do_not_wait_on_self() {
+        let directory = tempfile::tempdir().unwrap();
+        let block_size = 256 * 1024;
+        let data = Bytes::from(vec![42; 32 * block_size]);
+        let cache = Arc::new(
+            LocalCache::new(LocalCacheConfig {
+                shared: true,
+                dir: Some(directory.path().to_path_buf()),
+                namespace: "test".into(),
+                max_size: Some(16 * 1024 * 1024),
+                block_size: block_size as u64,
+                whitelist: std::collections::HashSet::from([FileType::Meta]),
+            })
+            .unwrap(),
+        );
+        for (a, b) in [(15, 27), (20, 28), (16, 31)] {
+            let a = cache.block_key("snapshot-1", a).cache_relative_path();
+            let b = cache.block_key("snapshot-1", b).cache_relative_path();
+            assert_eq!(a.components().next(), b.components().next());
+        }
+        let delegate = Arc::new(CountingReader {
+            data: data.clone(),
+            reads: AtomicUsize::new(0),
+        });
+        let reader = CachedFileReader::new(
+            delegate.clone(),
+            "snapshot-1",
+            data.len() as u64,
+            cache.clone(),
+        );
+        let result = tokio::time::timeout(
+            std::time::Duration::from_secs(2),
+            reader.read(0..data.len() as u64),
+        )
+        .await
+        .expect("colliding stripes must not wait for their own lock timeout")
+        .unwrap();
+        assert_eq!(result, data);
+        assert_eq!(delegate.reads.load(Ordering::SeqCst), 1);
+        let _fills = tokio::time::timeout(
+            std::time::Duration::from_secs(2),
+            cache.fill_slots.clone().acquire_many_owned(8),
+        )
+        .await
+        .unwrap()
+        .unwrap();
+        assert_eq!(reader.read(0..data.len() as u64).await.unwrap(), data);
+        assert_eq!(delegate.reads.load(Ordering::SeqCst), 1);
+    }
+
+    #[tokio::test]
+    async fn test_coalesced_miss_does_not_republish_after_invalidation() {
+        let cache = test_cache();
+        let started = Arc::new(tokio::sync::Notify::new());
+        let release = Arc::new(tokio::sync::Notify::new());
+        let old = CachedFileReader::new(
+            Arc::new(BlockingReader {
+                data: Bytes::from_static(b"old!old!old!"),
+                started: started.clone(),
+                release: release.clone(),
+            }),
+            "snapshot-1",
+            12,
+            cache.clone(),
+        );
+        let loading = tokio::spawn(async move { old.read(0..12).await });
+        started.notified().await;
+        cache.invalidate_path("snapshot-1").await.unwrap();
+        release.notify_one();
+        assert_eq!(
+            loading.await.unwrap().unwrap(),
+            Bytes::from_static(b"old!old!old!")
+        );
+        let delegate = Arc::new(CountingReader {
+            data: Bytes::from_static(b"new!new!new!"),
+            reads: AtomicUsize::new(0),
+        });
+        let current = CachedFileReader::new(delegate.clone(), "snapshot-1", 
12, cache);
+        assert_eq!(
+            current.read(0..12).await.unwrap(),
+            Bytes::from_static(b"new!new!new!")
+        );
+        assert_eq!(delegate.reads.load(Ordering::SeqCst), 1);
+    }
+
     #[async_trait::async_trait]
     impl FileRead for CountingReader {
         async fn read(&self, range: Range<u64>) -> crate::Result<Bytes> {
@@ -307,12 +582,12 @@ mod tests {
             reader.read(2..10).await.unwrap(),
             Bytes::from_static(b"cdefghij")
         );
-        assert_eq!(delegate.reads.load(Ordering::SeqCst), 3);
+        assert_eq!(delegate.reads.load(Ordering::SeqCst), 1);
         assert_eq!(
             reader.read(2..10).await.unwrap(),
             Bytes::from_static(b"cdefghij")
         );
-        assert_eq!(delegate.reads.load(Ordering::SeqCst), 3);
+        assert_eq!(delegate.reads.load(Ordering::SeqCst), 1);
     }
 
     #[tokio::test]
@@ -356,6 +631,69 @@ mod tests {
         assert_eq!(delegate.reads.load(Ordering::SeqCst), 5);
     }
 
+    #[derive(Debug)]
+    struct ConcurrentReader {
+        barrier: tokio::sync::Barrier,
+        reads: AtomicUsize,
+    }
+
+    #[async_trait::async_trait]
+    impl FileRead for ConcurrentReader {
+        async fn read(&self, range: Range<u64>) -> crate::Result<Bytes> {
+            self.reads.fetch_add(1, Ordering::SeqCst);
+            self.barrier.wait().await;
+            Ok(Bytes::from(vec![42; (range.end - range.start) as usize]))
+        }
+    }
+
+    #[tokio::test(flavor = "multi_thread", worker_threads = 4)]
+    async fn coalesced_disjoint_shared_reads_remain_concurrent() {
+        let directory = tempfile::tempdir().unwrap();
+        let size = 8 * 1024 * 1024;
+        let cache = Arc::new(
+            LocalCache::new(LocalCacheConfig {
+                shared: true,
+                dir: Some(directory.path().to_path_buf()),
+                namespace: "test".into(),
+                max_size: Some(128 * 1024 * 1024),
+                block_size: 256 * 1024,
+                whitelist: std::collections::HashSet::from([FileType::Meta]),
+            })
+            .unwrap(),
+        );
+        let source = Arc::new(ConcurrentReader {
+            barrier: tokio::sync::Barrier::new(8),
+            reads: AtomicUsize::new(0),
+        });
+        let reader = Arc::new(CachedFileReader::new(
+            source.clone(),
+            "snapshot-1",
+            8 * size,
+            cache.clone(),
+        ));
+        let reads = (0..8).map(|i| {
+            let reader = reader.clone();
+            async move {
+                let data = reader.read(i * size..(i + 1) * 
size).await.unwrap();
+                assert_eq!(data.len(), size as usize);
+                assert!(data.iter().all(|byte| *byte == 42));
+            }
+        });
+        tokio::time::timeout(
+            std::time::Duration::from_secs(2),
+            futures::future::join_all(reads),
+        )
+        .await
+        .expect("all eight disjoint source reads must overlap");
+        assert_eq!(source.reads.load(Ordering::SeqCst), 8);
+        let _fills = cache
+            .fill_slots
+            .clone()
+            .acquire_many_owned(8)
+            .await
+            .unwrap();
+    }
+
     #[derive(Debug)]
     struct SlowCountingReader {
         data: Bytes,
@@ -427,6 +765,62 @@ mod tests {
         drop(permits);
     }
 
+    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
+    async fn coalesced_shared_fill_preserves_single_flight() {
+        let directory = tempfile::tempdir().unwrap();
+        let delegate = Arc::new(CountingReader {
+            data: Bytes::from_static(b"abcdefgh"),
+            reads: AtomicUsize::new(0),
+        });
+        let cache = Arc::new(
+            LocalCache::new(LocalCacheConfig {
+                shared: true,
+                dir: Some(directory.path().to_path_buf()),
+                namespace: "test".into(),
+                max_size: Some(4096),
+                block_size: 4,
+                whitelist: std::collections::HashSet::from([FileType::Meta]),
+            })
+            .unwrap(),
+        );
+        let reader = CachedFileReader::new(delegate.clone(), "data.parquet", 
8, cache.clone());
+        let gate = std::fs::OpenOptions::new()
+            .read(true)
+            .write(true)
+            .open(
+                directory
+                    .path()
+                    .join("paimon-shared-local-cache-v1/directory.lock"),
+            )
+            .unwrap();
+        gate.lock().unwrap();
+        assert_eq!(
+            tokio::time::timeout(std::time::Duration::from_millis(500), 
reader.read(0..8))
+                .await
+                .unwrap()
+                .unwrap(),
+            "abcdefgh"
+        );
+        assert_eq!(cache.fill_slots.available_permits(), 6);
+        assert!(
+            tokio::time::timeout(std::time::Duration::from_millis(50), 
reader.read(0..8))
+                .await
+                .is_err()
+        );
+        assert_eq!(delegate.reads.load(Ordering::SeqCst), 1);
+        drop(gate);
+        assert_eq!(reader.read(0..8).await.unwrap(), "abcdefgh");
+        assert_eq!(delegate.reads.load(Ordering::SeqCst), 1);
+        let permits = cache
+            .fill_slots
+            .clone()
+            .acquire_many_owned(8)
+            .await
+            .unwrap();
+        assert!(cache.background_fill_slot().is_none());
+        drop(permits);
+    }
+
     #[test]
     fn shared_fill_does_not_block_reads_on_another_runtime() {
         let directory = tempfile::tempdir().unwrap();
diff --git a/crates/paimon/src/io/cache/shared.rs 
b/crates/paimon/src/io/cache/shared.rs
index f5b3dcdc..56db9c2a 100644
--- a/crates/paimon/src/io/cache/shared.rs
+++ b/crates/paimon/src/io/cache/shared.rs
@@ -305,12 +305,38 @@ impl SharedDiskCache {
     pub(crate) async fn load_lock(&self, key: &BlockKey) -> Option<File> {
         // Best-effort block coalescing; read_full locks only the first block.
         // Publication safety relies on the directory lock and generation 
checks.
+        lock(self.load_lock_path(key)?).await
+    }
+
+    pub(crate) async fn load_locks(&self, keys: &[BlockKey]) -> Vec<File> {
+        let mut paths: Vec<_> = keys
+            .iter()
+            .filter_map(|key| self.load_lock_path(key))
+            .collect();
+        paths.sort_unstable();
+        paths.dedup();
+        // Busy stripes may belong to unrelated blocks; never wait while 
holding a batch.
+        tokio::task::spawn_blocking(move || {
+            let mut guards = Vec::new();
+            for path in paths {
+                if let Ok(file) = open_lock(&path) {
+                    if file.try_lock().is_ok() {
+                        guards.push(file);
+                    }
+                }
+            }
+            guards
+        })
+        .await
+        .unwrap_or_default()
+    }
+
+    fn load_lock_path(&self, key: &BlockKey) -> Option<PathBuf> {
         let stripe = key.cache_relative_path();
-        lock(self.root.join(format!(
+        Some(self.root.join(format!(
             "load-{}.lock",
             stripe.components().next()?.as_os_str().to_string_lossy()
         )))
-        .await
     }
 
     pub(crate) async fn get(&self, key: &BlockKey, generation: 
&Option<String>) -> Option<Bytes> {
@@ -1093,6 +1119,28 @@ mod tests {
         drop(other);
     }
 
+    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
+    async fn batch_download_locks_deduplicate_and_skip_busy_stripes() {
+        let directory = tempfile::tempdir().unwrap();
+        let first = SharedDiskCache::new(directory.path().to_path_buf(), 1024 
* 1024).unwrap();
+        let second = SharedDiskCache::new(directory.path().to_path_buf(), 1024 
* 1024).unwrap();
+        let a = BlockKey::with_namespace("test", "snapshot-1", 256 * 1024, 15);
+        let b = BlockKey::with_namespace("test", "snapshot-1", 256 * 1024, 20);
+        let collision = BlockKey::with_namespace("test", "snapshot-1", 256 * 
1024, 27);
+        assert_eq!(first.load_lock_path(&a), first.load_lock_path(&collision));
+        assert_ne!(first.load_lock_path(&a), first.load_lock_path(&b));
+        let forward = [a.clone(), b.clone(), collision.clone()];
+        let reverse = [collision, b, a];
+        let guards = first.load_locks(&forward).await;
+        assert_eq!(guards.len(), 2);
+        let contended = tokio::time::timeout(Duration::from_secs(1), 
second.load_locks(&reverse))
+            .await
+            .expect("busy stripes must not block another batch");
+        assert!(contended.is_empty());
+        drop(guards);
+        assert_eq!(second.load_locks(&reverse).await.len(), 2);
+    }
+
     #[tokio::test]
     async fn interrupted_temporary_blocks_are_removed_before_clean_startup() {
         for invalidate in [false, true] {

Reply via email to