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 8d08e48a [core] Reduce shared disk cache lock contention (#1186)
8d08e48a is described below

commit 8d08e48a09d9119b701e993cf09bf2e83d08943c
Author: XiaoHongbo <[email protected]>
AuthorDate: Wed Oct 21 20:03:19 2026 +0800

    [core] Reduce shared disk cache lock contention (#1186)
---
 crates/paimon/src/io/cache/disk.rs   |  44 +++-
 crates/paimon/src/io/cache/mod.rs    |  20 ++
 crates/paimon/src/io/cache/reader.rs | 117 +++++++++-
 crates/paimon/src/io/cache/shared.rs | 426 +++++++++++++++++++++++++++++++----
 docs/src/getting-started.md          |   6 +
 5 files changed, 554 insertions(+), 59 deletions(-)

diff --git a/crates/paimon/src/io/cache/disk.rs 
b/crates/paimon/src/io/cache/disk.rs
index 3b217e04..baf25235 100644
--- a/crates/paimon/src/io/cache/disk.rs
+++ b/crates/paimon/src/io/cache/disk.rs
@@ -693,14 +693,22 @@ fn encode_block(key: &BlockKey, payload: &Bytes) -> 
Vec<u8> {
 }
 
 fn decode_block(key: &BlockKey, encoded: &[u8]) -> Result<Bytes, 
BlockDecodeError> {
-    let (decoded_key, payload) = decode_block_any(encoded)?;
+    let (decoded_key, payload) = validate_block(encoded)?;
     if &decoded_key != key {
         return Err(BlockDecodeError("cache block key does not match"));
     }
-    Ok(payload)
+    Ok(Bytes::copy_from_slice(&encoded[payload]))
 }
 
-fn decode_block_any(encoded: &[u8]) -> Result<(BlockKey, Bytes), 
BlockDecodeError> {
+fn decode_block_owned(key: &BlockKey, encoded: Vec<u8>) -> Result<Bytes, 
BlockDecodeError> {
+    let (decoded_key, payload) = validate_block(&encoded)?;
+    if &decoded_key != key {
+        return Err(BlockDecodeError("cache block key does not match"));
+    }
+    Ok(Bytes::from(encoded).slice(payload))
+}
+
+fn validate_block(encoded: &[u8]) -> Result<(BlockKey, 
std::ops::Range<usize>), 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"))?;
@@ -717,10 +725,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, layout.header_len..payload_end))
 }
 
 #[derive(Clone, Copy, Debug)]
@@ -846,6 +851,31 @@ mod tests {
         assert_eq!(decoded, payload);
     }
 
+    #[test]
+    fn test_owned_block_decode_reuses_allocation_and_validates() {
+        let key = BlockKey::new("s3://bucket/table/data.parquet", 1024, 3);
+        let payload = Bytes::from_static(b"cached block");
+        let encoded = encode_block(&key, &payload);
+        let (_, range) = validate_block(&encoded).unwrap();
+        let expected_address = encoded.as_ptr() as usize + range.start;
+        let decoded = decode_block_owned(&key, encoded).unwrap();
+        assert_eq!(decoded, payload);
+        assert_eq!(decoded.as_ptr() as usize, expected_address);
+
+        let wrong_key = BlockKey::new("s3://bucket/table/data.parquet", 1024, 
4);
+        assert!(decode_block_owned(&wrong_key, encode_block(&key, 
&payload)).is_err());
+        let encoded = encode_block(&key, &payload);
+        for end in 0..encoded.len() {
+            assert!(decode_block_owned(&key, 
encoded[..end].to_vec()).is_err());
+        }
+        let mut corrupt = encoded;
+        corrupt[range.start] ^= 1;
+        assert!(decode_block_owned(&key, corrupt).is_err());
+        assert!(decode_block_owned(&key, encode_block(&key, &Bytes::new()))
+            .unwrap()
+            .is_empty());
+    }
+
     #[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/mod.rs 
b/crates/paimon/src/io/cache/mod.rs
index 701f305b..5891f283 100644
--- a/crates/paimon/src/io/cache/mod.rs
+++ b/crates/paimon/src/io/cache/mod.rs
@@ -48,6 +48,8 @@ pub(crate) struct LocalCache {
     block_size: u64,
     whitelist: HashSet<FileType>,
     file_size_capacity: usize,
+    // Bound queued and active background fills.
+    fill_slots: Arc<tokio::sync::Semaphore>,
 }
 
 #[derive(Debug)]
@@ -76,6 +78,7 @@ impl LocalCache {
             block_size,
             whitelist: FileType::parse_whitelist(whitelist),
             file_size_capacity: DEFAULT_FILE_SIZE_CAPACITY,
+            fill_slots: Arc::new(tokio::sync::Semaphore::new(8)),
         })
     }
 
@@ -124,6 +127,7 @@ impl LocalCache {
             block_size: config.block_size,
             whitelist: config.whitelist,
             file_size_capacity,
+            fill_slots: Arc::new(tokio::sync::Semaphore::new(8)),
         })
     }
 
@@ -131,6 +135,22 @@ impl LocalCache {
         self.block_size
     }
 
+    fn background_fill_slot(&self) -> 
Option<tokio::sync::OwnedSemaphorePermit> {
+        // Current-thread runtimes stop driving spawned fills when block_on 
returns.
+        // Keep those fills synchronous so their block locks cannot outlive 
the read.
+        if !matches!(
+            tokio::runtime::Handle::try_current().map(|handle| 
handle.runtime_flavor()),
+            Ok(tokio::runtime::RuntimeFlavor::MultiThread)
+        ) {
+            return None;
+        }
+        // Limit background payloads to 8 MiB per cache; otherwise fill 
synchronously.
+        if !matches!(self.backend, CacheBackend::SharedDisk(_)) || 
self.block_size > 1024 * 1024 {
+            return None;
+        }
+        self.fill_slots.clone().try_acquire_owned().ok()
+    }
+
     fn block_key(&self, path: &str, block_index: u64) -> BlockKey {
         BlockKey::with_namespace(&self.namespace, path, self.block_size, 
block_index)
     }
diff --git a/crates/paimon/src/io/cache/reader.rs 
b/crates/paimon/src/io/cache/reader.rs
index bcb83ddd..35781cc5 100644
--- a/crates/paimon/src/io/cache/reader.rs
+++ b/crates/paimon/src/io/cache/reader.rs
@@ -82,8 +82,8 @@ impl CachedFileReader {
         }
 
         let load_lock = self.cache.block_load_lock(&key).await;
-        let load_guard = load_lock.lock().await;
-        let _shared_guard = self.cache.shared_load_lock(&key).await;
+        let load_guard = load_lock.clone().lock_owned().await;
+        let shared_guard = self.cache.shared_load_lock(&key).await;
         let result = if let Some(payload) = self
             .cache
             .get_block(&key, expected_len, &self.read_token)
@@ -107,6 +107,20 @@ impl CachedFileReader {
         } else {
             match self.delegate.read(start..end).await {
                 Ok(payload) if payload.len() == expected_len => {
+                    if let Some(slot) = self.cache.background_fill_slot() {
+                        let cache = self.cache.clone();
+                        let token = self.read_token.clone();
+                        let fill_payload = payload.clone();
+                        // Hold download guards until publication completes.
+                        tokio::spawn(async move {
+                            let _slot = slot;
+                            let _shared_guard = shared_guard;
+                            cache.put_block(&key, fill_payload, &token).await;
+                            drop(load_guard);
+                            cache.release_block_load_lock(&key, 
&load_lock).await;
+                        });
+                        return Ok(payload);
+                    }
                     self.cache
                         .put_block(&key, payload.clone(), &self.read_token)
                         .await;
@@ -125,6 +139,7 @@ impl CachedFileReader {
                 Err(error) => Err(error),
             }
         };
+        drop(shared_guard);
         drop(load_guard);
         self.cache.release_block_load_lock(&key, &load_lock).await;
         result
@@ -356,6 +371,104 @@ mod tests {
         }
     }
 
+    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
+    async fn 
shared_fill_returns_before_publication_and_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..4))
+                .await
+                .unwrap()
+                .unwrap(),
+            "abcd"
+        );
+        assert_eq!(cache.fill_slots.available_permits(), 7);
+        assert!(
+            tokio::time::timeout(std::time::Duration::from_millis(50), 
reader.read(0..4))
+                .await
+                .is_err()
+        );
+        assert_eq!(delegate.reads.load(Ordering::SeqCst), 1);
+        drop(gate);
+        assert_eq!(reader.read(0..4).await.unwrap(), "abcd");
+        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();
+        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);
+        let first_runtime = tokio::runtime::Builder::new_current_thread()
+            .enable_all()
+            .build()
+            .unwrap();
+        assert_eq!(first_runtime.block_on(reader.read(0..4)).unwrap(), "abcd");
+
+        // Keep the first runtime alive without driving its background tasks.
+        let second_runtime = tokio::runtime::Builder::new_current_thread()
+            .enable_all()
+            .build()
+            .unwrap();
+        let second = second_runtime.block_on(async {
+            tokio::time::timeout(std::time::Duration::from_millis(500), 
reader.read(0..4)).await
+        });
+        assert_eq!(
+            second
+                .expect("read must not wait for an idle runtime")
+                .unwrap(),
+            "abcd"
+        );
+        assert_eq!(delegate.reads.load(Ordering::SeqCst), 1);
+    }
+
     #[tokio::test]
     async fn test_cached_range_single_flight_reads_cold_block_once() {
         let delegate = Arc::new(SlowCountingReader {
diff --git a/crates/paimon/src/io/cache/shared.rs 
b/crates/paimon/src/io/cache/shared.rs
index 0ace0c0e..f5b3dcdc 100644
--- a/crates/paimon/src/io/cache/shared.rs
+++ b/crates/paimon/src/io/cache/shared.rs
@@ -16,12 +16,12 @@
 // under the License.
 
 //! Opt-in node-local cache. Block files, not a process-local index, are 
authoritative.
-//! A shared directory lock protects cache hits, while an exclusive lock 
serializes
-//! publication, eviction, and invalidation. File-striped locks coalesce 
downloads.
+//! Epoch validation protects immutable hits; an exclusive directory lock 
serializes
+//! publication, eviction, and invalidation. Block-striped locks coalesce 
downloads.
 
 use super::*;
 use std::fs::{File, OpenOptions};
-use std::io::{self, Write};
+use std::io::{self, Read, Write};
 use std::time::Duration;
 
 const MUTATION_MARKER: &str = "mutation-pending";
@@ -31,6 +31,88 @@ pub(crate) struct SharedDiskCache {
     root: PathBuf,
     max_size: u64,
     high_priority_ratio: f64,
+    staging_slots: Arc<tokio::sync::Semaphore>,
+}
+
+/// Unpublished inode, locked against cleanup and isolated from legacy 
temporaries.
+struct StagedBlock {
+    path: PathBuf,
+    _file: File,
+    len: u64,
+}
+
+impl StagedBlock {
+    fn create(root: &Path, encoded: &[u8]) -> io::Result<Self> {
+        let directory = root.join("staging");
+        std::fs::create_dir_all(&directory)?;
+        let path = directory.join(uuid::Uuid::new_v4().to_string());
+        let mut options = OpenOptions::new();
+        options.read(true).write(true).create_new(true);
+        #[cfg(unix)]
+        {
+            use std::os::unix::fs::OpenOptionsExt;
+            options.mode(0o600);
+        }
+        let file = options.open(&path)?;
+        let mut staged = Self {
+            path,
+            _file: file,
+            len: encoded.len() as u64,
+        };
+        // Cleanup may reclaim the inode before we lock it.
+        staged._file.try_lock().map_err(io::Error::other)?;
+        if !staged.path.try_exists()? {
+            return Err(io::Error::new(
+                io::ErrorKind::NotFound,
+                "staging inode was reclaimed",
+            ));
+        }
+        staged._file.write_all(encoded)?;
+        Ok(staged)
+    }
+}
+
+impl Drop for StagedBlock {
+    fn drop(&mut self) {
+        // Remove unpublished staging while still holding its lock.
+        let _ = std::fs::remove_file(&self.path);
+    }
+}
+
+fn cleanup_staging(root: &Path) -> io::Result<()> {
+    let entries = match std::fs::read_dir(root.join("staging")) {
+        Ok(entries) => entries,
+        Err(e) if e.kind() == io::ErrorKind::NotFound => return Ok(()),
+        Err(e) => return Err(e),
+    };
+    for entry in entries {
+        let entry = entry?;
+        if 
uuid::Uuid::parse_str(&entry.file_name().to_string_lossy()).is_err() {
+            continue;
+        }
+        match entry.file_type() {
+            Ok(kind) if kind.is_file() => (),
+            Ok(_) => continue,
+            Err(e) if e.kind() == io::ErrorKind::NotFound => continue,
+            Err(e) => return Err(e),
+        }
+        let file = match 
OpenOptions::new().read(true).write(true).open(entry.path()) {
+            Ok(file) => file,
+            Err(e) if e.kind() == io::ErrorKind::NotFound => continue,
+            Err(e) => return Err(e),
+        };
+        // Do not wait: the publisher may be waiting for our directory lock.
+        match file.try_lock() {
+            Ok(()) => match std::fs::remove_file(entry.path()) {
+                Ok(()) => (),
+                Err(e) if e.kind() == io::ErrorKind::NotFound => (),
+                Err(e) => return Err(e),
+            },
+            Err(std::fs::TryLockError::WouldBlock) => (),
+            Err(std::fs::TryLockError::Error(e)) => return Err(e),
+        }
+    }
+    Ok(())
 }
 
 fn open_lock(path: &Path) -> io::Result<File> {
@@ -69,6 +151,7 @@ async fn lock(path: PathBuf) -> Option<File> {
     }
 }
 
+#[cfg(test)]
 async fn read_lock(path: PathBuf) -> Option<File> {
     let file = tokio::task::spawn_blocking(move || open_lock(&path))
         .await
@@ -200,6 +283,7 @@ impl SharedDiskCache {
                 let used = recover_usage(&root, max_size, 
high_priority_ratio)?;
                 finish_usage(&root, used)?;
             }
+            cleanup_staging(&root)?;
             Ok(())
         };
         initialize().map_err(|e| crate::Error::ConfigInvalid {
@@ -209,6 +293,7 @@ impl SharedDiskCache {
             root,
             max_size,
             high_priority_ratio,
+            staging_slots: Arc::new(tokio::sync::Semaphore::new(8)),
         })
     }
 
@@ -218,9 +303,9 @@ impl SharedDiskCache {
     }
 
     pub(crate) async fn load_lock(&self, key: &BlockKey) -> Option<File> {
-        // All blocks of one file use the same stripe, also coordinating 
read_full.
-        let stripe =
-            BlockKey::with_namespace(&key.namespace, &key.path, 0, 
0).cache_relative_path();
+        // Best-effort block coalescing; read_full locks only the first block.
+        // Publication safety relies on the directory lock and generation 
checks.
+        let stripe = key.cache_relative_path();
         lock(self.root.join(format!(
             "load-{}.lock",
             stripe.components().next()?.as_os_str().to_string_lossy()
@@ -232,23 +317,39 @@ impl SharedDiskCache {
         if generation.is_none() {
             return None;
         }
-        let gate = read_lock(self.root.join("directory.lock")).await?;
         let root = self.root.clone();
         let key = generation_key(key, generation.as_ref()?);
         let generation = generation.clone();
         tokio::task::spawn_blocking(move || {
-            let _gate = gate;
             if !mutation_cleared(&root)
                 || std::fs::read_to_string(root.join("generation")).ok() != 
generation
             {
                 return None;
             }
             let path = root.join(key.cache_relative_path());
-            let data = std::fs::read(&path).ok()?;
-            let payload = decode_block(&key, &data).ok()?;
-            // Persist recency so eviction observes hits from every process.
-            if let Ok(file) = File::open(path) {
-                let _ = file.set_modified(std::time::SystemTime::now());
+            // Immutable inodes survive unlink; recheck the epoch after reading
+            // to reject concurrent invalidation.
+            let mut file = File::open(&path).ok()?;
+            let metadata = file.metadata().ok()?;
+            // Throttle recency writes for approximate cross-process LRU.
+            if metadata
+                .modified()
+                .ok()
+                .and_then(|t| t.elapsed().ok())
+                .is_some_and(|age| age >= Duration::from_secs(10))
+            {
+                // Windows requires a write-capable handle to update mtime.
+                // A failed touch must not turn a valid cached block into a 
miss.
+                let _ = OpenOptions::new()
+                    .write(true)
+                    .open(&path)
+                    .and_then(|file| 
file.set_modified(std::time::SystemTime::now()));
+            }
+            let mut data = 
Vec::with_capacity(usize::try_from(metadata.len()).ok()?);
+            file.read_to_end(&mut data).ok()?;
+            let payload = decode_block_owned(&key, data).ok()?;
+            if !mutation_cleared(&root) || read_generation(&root) != 
generation {
+                return None;
             }
             Some(payload)
         })
@@ -261,25 +362,57 @@ impl SharedDiskCache {
         if generation.is_none() {
             return;
         }
-        let Some(gate) = lock(self.root.join("directory.lock")).await else {
+        let Some(staged) = self.stage_block(key, payload, generation).await 
else {
             return;
         };
+        self.publish_staged(staged, generation).await;
+    }
+
+    async fn stage_block(
+        &self,
+        key: &BlockKey,
+        payload: Bytes,
+        generation: &Option<String>,
+    ) -> Option<(BlockKey, StagedBlock, tokio::sync::OwnedSemaphorePermit)> {
+        let root = self.root.clone();
+        let key = generation_key(key, generation.as_ref()?);
+        let max_size = self.max_size;
+        // Stage outside the directory lock; recheck the generation before 
publishing.
+        let slot = self.staging_slots.clone().acquire_owned().await.ok()?;
+        let staging_root = root.clone();
+        tokio::task::spawn_blocking(move || -> io::Result<_> {
+            let encoded = encode_block(&key, &payload);
+            if encoded.len() as u64 > max_size {
+                return Err(io::Error::other("cache block exceeds capacity"));
+            }
+            let staged = StagedBlock::create(&staging_root, &encoded)?;
+            Ok((key, staged, slot))
+        })
+        .await
+        .ok()?
+        .ok()
+    }
+
+    async fn publish_staged(
+        &self,
+        (key, staged, slot): (BlockKey, StagedBlock, 
tokio::sync::OwnedSemaphorePermit),
+        generation: &Option<String>,
+    ) {
         let root = self.root.clone();
-        let key = generation_key(key, generation.as_ref().unwrap());
         let generation = generation.clone();
         let max_size = self.max_size;
         let high_priority_ratio = self.high_priority_ratio;
+        let Some(gate) = lock(self.root.join("directory.lock")).await else {
+            return;
+        };
         let result = tokio::task::spawn_blocking(move || -> io::Result<()> {
             let _gate = gate;
+            let _slot = slot;
             if !mutation_cleared(&root)
                 || std::fs::read_to_string(root.join("generation")).ok() != 
generation
             {
                 return Ok(());
             }
-            let encoded = encode_block(&key, &payload);
-            if encoded.len() as u64 > max_size {
-                return Ok(());
-            }
             let path = root.join(key.cache_relative_path());
             let mut used = if root.join("dirty").exists() {
                 recover_usage(&root, max_size, high_priority_ratio)?
@@ -296,47 +429,23 @@ impl SharedDiskCache {
             // A killed process leaves it for the next writer to recover.
             std::fs::write(root.join("dirty"), b"")?;
             let mut previous = std::fs::metadata(&path).map(|m| 
m.len()).unwrap_or(0);
-            if used
-                .saturating_sub(previous)
-                .saturating_add(encoded.len() as u64)
-                > max_size
-            {
+            if used.saturating_sub(previous).saturating_add(staged.len) > 
max_size {
                 // Evict in batches, not one full directory scan per new block.
                 used = recover_usage(
                     &root,
-                    (max_size - encoded.len() as u64).min(max_size - max_size 
/ 10),
+                    (max_size - staged.len).min(max_size - max_size / 10),
                     high_priority_ratio,
                 )?;
                 previous = std::fs::metadata(&path).map(|m| 
m.len()).unwrap_or(0);
             }
             let parent = path.parent().expect("cache shard");
             std::fs::create_dir_all(parent)?;
-            let temporary = parent.join(format!(
-                ".{}.tmp.{}",
-                path.file_name().unwrap().to_string_lossy(),
-                uuid::Uuid::new_v4()
-            ));
-            let mut options = OpenOptions::new();
-            options.write(true).create_new(true);
-            #[cfg(unix)]
             {
-                use std::os::unix::fs::OpenOptionsExt;
-                options.mode(0o600);
+                std::fs::rename(&staged.path, &path)?;
             }
-            let publish = (|| {
-                let mut file = options.open(&temporary)?;
-                file.write_all(&encoded)?;
-                drop(file);
-                std::fs::rename(&temporary, &path)
-            })();
-            if publish.is_err() {
-                let _ = std::fs::remove_file(&temporary);
-            }
-            publish?;
             finish_usage(
                 &root,
-                used.saturating_sub(previous)
-                    .saturating_add(encoded.len() as u64),
+                used.saturating_sub(previous).saturating_add(staged.len),
             )
         })
         .await;
@@ -422,6 +531,7 @@ fn finish_invalidation(root: &Path) -> io::Result<()> {
         let _ = std::fs::remove_file(root.join(key.cache_relative_path()));
     }
     cleanup_temporary_files(root)?;
+    cleanup_staging(root)?;
     finish_usage(root, existing_usage(root))
 }
 
@@ -445,13 +555,14 @@ fn clean_usage(root: &Path, max_size: u64) -> Option<u64> 
{
 }
 
 fn recover_usage(root: &Path, limit: u64, high_priority_ratio: f64) -> 
io::Result<u64> {
-    // Publication holds the directory lock, so every temporary block found
-    // here belongs to an interrupted publisher. Remove it before clearing 
dirty.
+    // Legacy temporaries are orphaned; staged files may still have live 
writers.
     cleanup_temporary_files(root)?;
+    cleanup_staging(root)?;
     #[cfg(test)]
     RECOVERY_SCANS.with(|count| count.set(count.get() + 1));
     let mut state = CacheState::new(Some(limit), high_priority_ratio);
-    for (key, encoded_size) in scan_existing_blocks(root) {
+    let blocks = scan_existing_blocks(root);
+    for (key, encoded_size) in blocks {
         insert_state_entry(&mut state, key, encoded_size);
     }
     for key in collect_evictions(&mut state) {
@@ -539,6 +650,11 @@ mod tests {
         let cache = SharedDiskCache::new(root.clone(), 4096).unwrap();
         let token = cache.generation();
         match std::env::var("PAIMON_SHARED_TEST_MODE").unwrap().as_str() {
+            "stage-hold" => {
+                let _staged = StagedBlock::create(&root, b"staged").unwrap();
+                std::fs::write(root.join("locked"), b"ready").unwrap();
+                std::future::pending::<()>().await;
+            }
             "populate" => {
                 let _guard = cache.load_lock(&key()).await.unwrap();
                 if cache.get(&key(), &token).await.is_none() {
@@ -666,6 +782,100 @@ mod tests {
         assert!(reopened.generation().is_none());
     }
 
+    #[tokio::test]
+    async fn staged_file_survives_recovery_and_publishes_after_gate_release() {
+        let directory = tempfile::tempdir().unwrap();
+        let cache = SharedDiskCache::new(directory.path().to_path_buf(), 
4096).unwrap();
+        let generation = cache.generation();
+        let staged = cache
+            .stage_block(&key(), Bytes::from_static(b"value"), &generation)
+            .await
+            .unwrap();
+        let staged_path = staged.1.path.clone();
+        let gate = 
open_lock(&directory.path().join("directory.lock")).unwrap();
+        gate.lock().unwrap();
+        assert_eq!(
+            recover_usage(directory.path(), 4096, 
DEFAULT_HIGH_PRIORITY_POOL_RATIO).unwrap(),
+            0
+        );
+        assert!(staged_path.exists());
+        drop(gate);
+        cache.publish_staged(staged, &generation).await;
+        assert!(!staged_path.exists());
+        assert_eq!(cache.get(&key(), &generation).await.unwrap(), "value");
+        assert_eq!(
+            std::fs::read_to_string(directory.path().join("usage"))
+                .unwrap()
+                .parse::<u64>()
+                .unwrap(),
+            existing_usage(directory.path())
+        );
+    }
+
+    #[test]
+    fn process_exit_releases_staging_for_cleanup() {
+        let directory = tempfile::tempdir().unwrap();
+        let mut process = child(directory.path(), 
"stage-hold").spawn().unwrap();
+        let deadline = std::time::Instant::now() + Duration::from_secs(5);
+        while !directory.path().join("locked").exists() && 
std::time::Instant::now() < deadline {
+            std::thread::sleep(Duration::from_millis(5));
+        }
+        let ready = directory.path().join("locked").exists();
+        cleanup_staging(directory.path()).unwrap();
+        let live = std::fs::read_dir(directory.path().join("staging"))
+            .unwrap()
+            .count();
+        process.kill().unwrap();
+        process.wait().unwrap();
+        assert!(ready);
+        assert_eq!(live, 1);
+        SharedDiskCache::new(directory.path().to_path_buf(), 4096).unwrap();
+        assert_eq!(
+            std::fs::read_dir(directory.path().join("staging"))
+                .unwrap()
+                .count(),
+            0
+        );
+    }
+
+    #[test]
+    fn dropping_unpublished_stage_removes_it() {
+        let directory = tempfile::tempdir().unwrap();
+        let staged = StagedBlock::create(directory.path(), b"value").unwrap();
+        let path = staged.path.clone();
+        assert!(path.exists());
+        drop(staged);
+        assert!(!path.exists());
+    }
+
+    #[tokio::test]
+    async fn encoded_publication_cannot_cross_invalidation() {
+        let directory = tempfile::tempdir().unwrap();
+        let cache = std::sync::Arc::new(
+            SharedDiskCache::new(directory.path().to_path_buf(), 4 * 1024 * 
1024).unwrap(),
+        );
+        let old = cache.generation();
+        let mutation = cache.begin_invalidation().await.unwrap();
+        let writer = {
+            let cache = cache.clone();
+            tokio::spawn(async move {
+                cache
+                    .put(&key(), Bytes::from(vec![42; 1024 * 1024]), &old)
+                    .await;
+            })
+        };
+        // Reject the old generation regardless of when encoding finishes.
+        tokio::task::yield_now().await;
+        mutation.finish().await;
+        writer.await.unwrap();
+        let current = cache.generation();
+        assert!(cache.get(&key(), &current).await.is_none());
+        cache
+            .put(&key(), Bytes::from_static(b"current"), &current)
+            .await;
+        assert_eq!(cache.get(&key(), &current).await.unwrap(), "current");
+    }
+
     #[tokio::test]
     async fn cache_hits_can_share_the_directory_read_lock() {
         let directory = tempfile::tempdir().unwrap();
@@ -703,6 +913,87 @@ mod tests {
         assert!(guard.is_some());
     }
 
+    #[tokio::test]
+    async fn hit_does_not_wait_for_directory_writer() {
+        let directory = tempfile::tempdir().unwrap();
+        let cache = SharedDiskCache::new(directory.path().to_path_buf(), 
4096).unwrap();
+        let token = cache.generation();
+        cache
+            .put(&key(), Bytes::from_static(b"value"), &token)
+            .await;
+        let gate = 
open_lock(&directory.path().join("directory.lock")).unwrap();
+        gate.lock().unwrap();
+        assert_eq!(
+            tokio::time::timeout(Duration::from_millis(500), cache.get(&key(), 
&token))
+                .await
+                .unwrap()
+                .unwrap(),
+            "value"
+        );
+        // A marker alone must fail closed, even before generation rotation.
+        std::fs::write(directory.path().join(MUTATION_MARKER), b"").unwrap();
+        assert!(cache.get(&key(), &token).await.is_none());
+    }
+
+    #[tokio::test]
+    async fn 
opened_block_survives_invalidation_but_new_reads_reject_old_epoch() {
+        let directory = tempfile::tempdir().unwrap();
+        let cache = SharedDiskCache::new(directory.path().to_path_buf(), 
4096).unwrap();
+        let token = cache.generation();
+        cache.put(&key(), Bytes::from_static(b"old"), &token).await;
+        let encoded_key = generation_key(&key(), token.as_ref().unwrap());
+        let gate = read_lock(directory.path().join("directory.lock"))
+            .await
+            .unwrap();
+        let mut opened =
+            
File::open(directory.path().join(encoded_key.cache_relative_path())).unwrap();
+        drop(gate);
+        cache.invalidate().await.unwrap();
+        assert!(cache.get(&key(), &token).await.is_none());
+        let current = cache.generation();
+        cache
+            .put(&key(), Bytes::from_static(b"new"), &current)
+            .await;
+        let mut bytes = Vec::new();
+        opened.read_to_end(&mut bytes).unwrap();
+        assert_eq!(decode_block_owned(&encoded_key, bytes).unwrap(), "old");
+        assert_eq!(cache.get(&key(), &current).await.unwrap(), "new");
+    }
+
+    #[tokio::test]
+    async fn hits_refresh_stale_recency_but_do_not_touch_recent_blocks() {
+        let directory = tempfile::tempdir().unwrap();
+        let cache = SharedDiskCache::new(directory.path().to_path_buf(), 
4096).unwrap();
+        let token = cache.generation();
+        cache
+            .put(&key(), Bytes::from_static(b"value"), &token)
+            .await;
+        let path = directory
+            .path()
+            .join(generation_key(&key(), 
token.as_ref().unwrap()).cache_relative_path());
+        let recent = std::time::SystemTime::now() - Duration::from_secs(1);
+        OpenOptions::new()
+            .write(true)
+            .open(&path)
+            .unwrap()
+            .set_modified(recent)
+            .unwrap();
+        let before = std::fs::metadata(&path).unwrap().modified().unwrap();
+        assert!(cache.get(&key(), &token).await.is_some());
+        assert_eq!(
+            std::fs::metadata(&path).unwrap().modified().unwrap(),
+            before
+        );
+        OpenOptions::new()
+            .write(true)
+            .open(&path)
+            .unwrap()
+            .set_modified(recent - Duration::from_secs(60))
+            .unwrap();
+        assert!(cache.get(&key(), &token).await.is_some());
+        assert!(std::fs::metadata(&path).unwrap().modified().unwrap() > 
before);
+    }
+
     #[tokio::test]
     async fn capacity_and_corruption() {
         let directory = tempfile::tempdir().unwrap();
@@ -767,6 +1058,41 @@ mod tests {
         assert!(used <= 512);
     }
 
+    #[tokio::test]
+    async fn download_locks_allow_different_blocks_of_the_same_file() {
+        let directory = tempfile::tempdir().unwrap();
+        let cache = SharedDiskCache::new(directory.path().to_path_buf(), 1024 
* 1024).unwrap();
+        let first = key();
+        let mut second = first.clone();
+        // Select a different stripe; collisions remain intentionally bounded.
+        loop {
+            second.block_index += 1;
+            if first.cache_relative_path().components().next()
+                != second.cache_relative_path().components().next()
+            {
+                break;
+            }
+        }
+        let guard = cache.load_lock(&first).await.unwrap();
+        let other = tokio::time::timeout(Duration::from_secs(1), 
cache.load_lock(&second))
+            .await
+            .expect("another block must not wait for this file's download")
+            .unwrap();
+        assert!(
+            tokio::time::timeout(Duration::from_millis(50), 
cache.load_lock(&first))
+                .await
+                .is_err()
+        );
+        drop(guard);
+        assert!(
+            tokio::time::timeout(Duration::from_secs(1), 
cache.load_lock(&first))
+                .await
+                .unwrap()
+                .is_some()
+        );
+        drop(other);
+    }
+
     #[tokio::test]
     async fn interrupted_temporary_blocks_are_removed_before_clean_startup() {
         for invalidate in [false, true] {
diff --git a/docs/src/getting-started.md b/docs/src/getting-started.md
index dcfa30e6..b6c8ff9b 100644
--- a/docs/src/getting-started.md
+++ b/docs/src/getting-started.md
@@ -436,6 +436,12 @@ do not reuse the affected path until invalidation succeeds.
 A canceled or failed rename bypasses this cache; after confirming storage 
state,
 use a new cache directory to resume sharing.
 
+Shared range reads allow eight background fills per cache instance for blocks
+up to 1 MiB; larger blocks, whole-file reads, or saturated fills stay 
synchronous.
+Concurrent whole-file and range reads may download overlapping data.
+Allow disk headroom beyond `local-cache.max-size` for up to eight staged writes
+per cache instance; this staging limit is not node-wide.
+
 ### BLOB Index Cache
 
 Decoded `.blob` indexes are cached per catalog. Set

Reply via email to