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 e4e27ee8 feat(io): support in-memory local cache (#710)
e4e27ee8 is described below
commit e4e27ee8d8c2036191a5986f1c707cb639e5ea60
Author: jerry <[email protected]>
AuthorDate: Sat Aug 15 16:15:46 2026 +0800
feat(io): support in-memory local cache (#710)
---
Cargo.lock | 12 +-
DEPENDENCIES.rust.tsv | 1 +
benchmarks/tpcds/DEPENDENCIES.rust.tsv | 1 +
bindings/c/DEPENDENCIES.rust.tsv | 3 +
bindings/go/DEPENDENCIES.rust.tsv | 3 +
bindings/python/DEPENDENCIES.rust.tsv | 1 +
crates/integration_tests/DEPENDENCIES.rust.tsv | 3 +
.../integrations/datafusion/DEPENDENCIES.rust.tsv | 1 +
crates/paimon-rest-server/DEPENDENCIES.rust.tsv | 3 +
crates/paimon/Cargo.toml | 1 +
crates/paimon/DEPENDENCIES.rust.tsv | 1 +
crates/paimon/src/catalog/filesystem.rs | 5 -
crates/paimon/src/catalog/rest/rest_catalog.rs | 5 -
.../paimon/src/catalog/rest/rest_token_file_io.rs | 5 -
crates/paimon/src/common/options.rs | 57 +++-
crates/paimon/src/io/cache/disk.rs | 192 +-------------
crates/paimon/src/io/cache/memory.rs | 139 ++++++++++
crates/paimon/src/io/cache/mod.rs | 187 +++++++------
crates/paimon/src/io/cache/reader.rs | 57 ++--
crates/paimon/src/io/cache/state.rs | 295 +++++++++++++++++++++
crates/paimon/src/spec/core_options.rs | 4 +
docs/src/getting-started.md | 30 +--
22 files changed, 681 insertions(+), 325 deletions(-)
diff --git a/Cargo.lock b/Cargo.lock
index 9f078c3b..a8e8270c 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -3790,6 +3790,15 @@ dependencies = [
"hashbrown 0.16.1",
]
+[[package]]
+name = "lru"
+version = "0.18.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5d2f2f9b4ba7e6b24d95e7e899329d35be83bcded72c8540cdd5368932d1d90a"
+dependencies = [
+ "hashbrown 0.17.1",
+]
+
[[package]]
name = "lru-slab"
version = "0.1.2"
@@ -4576,6 +4585,7 @@ dependencies = [
"indexmap 2.14.0",
"libloading 0.9.0",
"log",
+ "lru 0.18.2",
"lz4_flex 0.13.1",
"md-5 0.10.6",
"opendal-core",
@@ -6700,7 +6710,7 @@ dependencies = [
"itertools 0.14.0",
"levenshtein_automata",
"log",
- "lru",
+ "lru 0.16.4",
"lz4_flex 0.13.1",
"measure_time",
"memmap2",
diff --git a/DEPENDENCIES.rust.tsv b/DEPENDENCIES.rust.tsv
index f2985fdb..912dea88 100644
--- a/DEPENDENCIES.rust.tsv
+++ b/DEPENDENCIES.rust.tsv
@@ -324,6 +324,7 @@ [email protected]
X
[email protected] X
X
[email protected] X
X
[email protected]
X
[email protected]
X
[email protected]
X
[email protected]
X
[email protected]
X
diff --git a/benchmarks/tpcds/DEPENDENCIES.rust.tsv
b/benchmarks/tpcds/DEPENDENCIES.rust.tsv
index e31ea76a..a871ab1e 100644
--- a/benchmarks/tpcds/DEPENDENCIES.rust.tsv
+++ b/benchmarks/tpcds/DEPENDENCIES.rust.tsv
@@ -229,6 +229,7 @@ [email protected] X X
X
[email protected]
X
[email protected] X
X
[email protected] X
X
[email protected]
X
[email protected]
X
[email protected]
X
[email protected]
X
diff --git a/bindings/c/DEPENDENCIES.rust.tsv b/bindings/c/DEPENDENCIES.rust.tsv
index a01464e3..9f5c62a9 100644
--- a/bindings/c/DEPENDENCIES.rust.tsv
+++ b/bindings/c/DEPENDENCIES.rust.tsv
@@ -4,6 +4,7 @@ [email protected] X
X
[email protected]
X X
[email protected] X
[email protected] X
[email protected] X
X
[email protected] X
X
[email protected] X
X
[email protected] X
@@ -90,6 +91,7 @@ [email protected] X
X
[email protected] X
[email protected] X
X
[email protected] X
X
[email protected]
X
[email protected] X
X
[email protected] X
X
[email protected] X
X
@@ -165,6 +167,7 @@ [email protected]
X
[email protected] X X
X
[email protected]
X
[email protected] X
X
[email protected]
X
[email protected]
X
[email protected]
X
[email protected]
X
diff --git a/bindings/go/DEPENDENCIES.rust.tsv
b/bindings/go/DEPENDENCIES.rust.tsv
index a01464e3..9f5c62a9 100644
--- a/bindings/go/DEPENDENCIES.rust.tsv
+++ b/bindings/go/DEPENDENCIES.rust.tsv
@@ -4,6 +4,7 @@ [email protected] X
X
[email protected]
X X
[email protected] X
[email protected] X
[email protected] X
X
[email protected] X
X
[email protected] X
X
[email protected] X
@@ -90,6 +91,7 @@ [email protected] X
X
[email protected] X
[email protected] X
X
[email protected] X
X
[email protected]
X
[email protected] X
X
[email protected] X
X
[email protected] X
X
@@ -165,6 +167,7 @@ [email protected]
X
[email protected] X X
X
[email protected]
X
[email protected] X
X
[email protected]
X
[email protected]
X
[email protected]
X
[email protected]
X
diff --git a/bindings/python/DEPENDENCIES.rust.tsv
b/bindings/python/DEPENDENCIES.rust.tsv
index 785a3f06..e3ec6090 100644
--- a/bindings/python/DEPENDENCIES.rust.tsv
+++ b/bindings/python/DEPENDENCIES.rust.tsv
@@ -268,6 +268,7 @@ [email protected]
X
[email protected] X
X
[email protected] X
X
[email protected]
X
[email protected]
X
[email protected]
X
[email protected]
X
[email protected]
X
diff --git a/crates/integration_tests/DEPENDENCIES.rust.tsv
b/crates/integration_tests/DEPENDENCIES.rust.tsv
index aa9bfc15..73588751 100644
--- a/crates/integration_tests/DEPENDENCIES.rust.tsv
+++ b/crates/integration_tests/DEPENDENCIES.rust.tsv
@@ -4,6 +4,7 @@ [email protected] X
X
[email protected]
X X
[email protected] X
[email protected] X
[email protected] X
X
[email protected] X
X
[email protected] X
X
[email protected] X
@@ -90,6 +91,7 @@ [email protected] X
X
[email protected] X
[email protected] X
X
[email protected] X
X
[email protected]
X
[email protected] X
X
[email protected] X
X
[email protected] X
X
@@ -165,6 +167,7 @@ [email protected]
X
[email protected] X X
X
[email protected]
X
[email protected] X
X
[email protected]
X
[email protected]
X
[email protected]
X
[email protected]
X
diff --git a/crates/integrations/datafusion/DEPENDENCIES.rust.tsv
b/crates/integrations/datafusion/DEPENDENCIES.rust.tsv
index 5e4ac46d..3089ed33 100644
--- a/crates/integrations/datafusion/DEPENDENCIES.rust.tsv
+++ b/crates/integrations/datafusion/DEPENDENCIES.rust.tsv
@@ -286,6 +286,7 @@ [email protected]
X
[email protected] X
X
[email protected] X
X
[email protected]
X
[email protected]
X
[email protected]
X
[email protected]
X
[email protected]
X
diff --git a/crates/paimon-rest-server/DEPENDENCIES.rust.tsv
b/crates/paimon-rest-server/DEPENDENCIES.rust.tsv
index 39dd29f6..80ae8801 100644
--- a/crates/paimon-rest-server/DEPENDENCIES.rust.tsv
+++ b/crates/paimon-rest-server/DEPENDENCIES.rust.tsv
@@ -4,6 +4,7 @@ [email protected] X
X
[email protected]
X X
[email protected] X
[email protected] X
[email protected] X
X
[email protected] X
X
[email protected] X
X
[email protected] X
@@ -93,6 +94,7 @@ [email protected] X
X
[email protected] X
[email protected] X
X
[email protected] X
X
[email protected]
X
[email protected] X
X
[email protected] X
X
[email protected] X
X
@@ -168,6 +170,7 @@ [email protected]
X
[email protected] X X
X
[email protected]
X
[email protected] X
X
[email protected]
X
[email protected]
X
[email protected]
X
[email protected]
X
diff --git a/crates/paimon/Cargo.toml b/crates/paimon/Cargo.toml
index 5c3475a1..d4049eb1 100644
--- a/crates/paimon/Cargo.toml
+++ b/crates/paimon/Cargo.toml
@@ -87,6 +87,7 @@ opendal-service-s3 = { version = "0.58.0", optional = true }
pretty_assertions = "1"
apache-avro = { version = "0.21", features = ["snappy", "zstandard"] }
indexmap = "2.5.0"
+lru = "0.18.2"
roaring = "0.11"
crc32fast = "1"
zstd = "0.13"
diff --git a/crates/paimon/DEPENDENCIES.rust.tsv
b/crates/paimon/DEPENDENCIES.rust.tsv
index 24855b1c..89b76f4f 100644
--- a/crates/paimon/DEPENDENCIES.rust.tsv
+++ b/crates/paimon/DEPENDENCIES.rust.tsv
@@ -257,6 +257,7 @@ [email protected]
X
[email protected] X
X
[email protected] X
X
[email protected]
X
[email protected]
X
[email protected]
X
[email protected]
X
[email protected]
X
diff --git a/crates/paimon/src/catalog/filesystem.rs
b/crates/paimon/src/catalog/filesystem.rs
index cf2f745a..ae015e25 100644
--- a/crates/paimon/src/catalog/filesystem.rs
+++ b/crates/paimon/src/catalog/filesystem.rs
@@ -510,17 +510,12 @@ mod tests {
#[test]
fn test_filesystem_catalog_builds_local_cache_from_catalog_options() {
let warehouse = TempDir::new().unwrap();
- let cache = TempDir::new().unwrap();
let mut options = Options::new();
options.set(
CatalogOptions::WAREHOUSE,
warehouse.path().to_string_lossy(),
);
options.set(CatalogOptions::LOCAL_CACHE_ENABLED, "true");
- options.set(
- CatalogOptions::LOCAL_CACHE_DIR,
- cache.path().to_string_lossy(),
- );
let catalog = FileSystemCatalog::new(options).unwrap();
diff --git a/crates/paimon/src/catalog/rest/rest_catalog.rs
b/crates/paimon/src/catalog/rest/rest_catalog.rs
index 7a19ba12..401545cb 100644
--- a/crates/paimon/src/catalog/rest/rest_catalog.rs
+++ b/crates/paimon/src/catalog/rest/rest_catalog.rs
@@ -548,17 +548,12 @@ mod tests {
#[tokio::test]
async fn test_rest_catalog_builds_local_cache_from_client_options() {
- let cache = tempfile::tempdir().unwrap();
let mut options = Options::new();
options.set(CatalogOptions::URI, "http://localhost:1");
options.set(CatalogOptions::WAREHOUSE, "test-warehouse");
options.set("token.provider", "bear");
options.set("token", "test-token");
options.set(CatalogOptions::LOCAL_CACHE_ENABLED, "true");
- options.set(
- CatalogOptions::LOCAL_CACHE_DIR,
- cache.path().to_string_lossy(),
- );
let catalog = RESTCatalog::new(options, false).await.unwrap();
diff --git a/crates/paimon/src/catalog/rest/rest_token_file_io.rs
b/crates/paimon/src/catalog/rest/rest_token_file_io.rs
index ee4212d2..c34967a9 100644
--- a/crates/paimon/src/catalog/rest/rest_token_file_io.rs
+++ b/crates/paimon/src/catalog/rest/rest_token_file_io.rs
@@ -229,14 +229,9 @@ mod tests {
#[tokio::test]
async fn test_token_file_io_keeps_catalog_local_cache() {
- let cache_directory = tempfile::tempdir().unwrap();
let table_directory = tempfile::tempdir().unwrap();
let (mut options, api, _, server) = token_api().await;
options.set(CatalogOptions::LOCAL_CACHE_ENABLED, "true");
- options.set(
- CatalogOptions::LOCAL_CACHE_DIR,
- cache_directory.path().to_string_lossy(),
- );
let local_cache = create_local_cache(&options).unwrap();
let token_file_io = Arc::new(RESTTokenFileIO::new(
Identifier::new("database", "table"),
diff --git a/crates/paimon/src/common/options.rs
b/crates/paimon/src/common/options.rs
index 5ad2147d..c23cd931 100644
--- a/crates/paimon/src/common/options.rs
+++ b/crates/paimon/src/common/options.rs
@@ -76,10 +76,10 @@ impl CatalogOptions {
/// Whether to enable local block caching for file reads.
pub const LOCAL_CACHE_ENABLED: &'static str = "local-cache.enabled";
- /// Directory for the local disk block cache.
+ /// Directory for the local disk block cache. If unset, an in-memory cache
is used.
pub const LOCAL_CACHE_DIR: &'static str = "local-cache.dir";
- /// Maximum total encoded size of the local block cache.
+ /// Maximum total size of the local block cache.
pub const LOCAL_CACHE_MAX_SIZE: &'static str = "local-cache.max-size";
/// Block size used by the local cache.
@@ -175,6 +175,40 @@ impl From<HashMap<String, String>> for Options {
}
}
+#[derive(Debug, Eq, PartialEq)]
+pub(crate) enum ParseMemorySizeError {
+ Invalid,
+ Overflow,
+}
+
+/// Parses Java Paimon memory sizes, plus the binary unit aliases previously
+/// accepted by the Rust local cache.
+pub(crate) fn parse_memory_size(value: &str) -> Result<i64,
ParseMemorySizeError> {
+ let value = value.trim();
+ if value.is_empty() {
+ return Err(ParseMemorySizeError::Invalid);
+ }
+
+ let pos = value
+ .find(|character: char| !character.is_ascii_digit())
+ .unwrap_or(value.len());
+ let (number, unit) = value.split_at(pos);
+ let number = number
+ .parse::<i64>()
+ .map_err(|_| ParseMemorySizeError::Invalid)?;
+ let multiplier = match unit.trim().to_ascii_lowercase().as_str() {
+ "" | "b" | "bytes" => 1,
+ "k" | "kb" | "kib" | "kibibytes" => 1024,
+ "m" | "mb" | "mib" | "mebibytes" => 1024 * 1024,
+ "g" | "gb" | "gib" | "gibibytes" => 1024 * 1024 * 1024,
+ "t" | "tb" | "tib" | "tebibytes" => 1024_i64.pow(4),
+ _ => return Err(ParseMemorySizeError::Invalid),
+ };
+ number
+ .checked_mul(multiplier)
+ .ok_or(ParseMemorySizeError::Overflow)
+}
+
#[cfg(test)]
mod tests {
use super::*;
@@ -244,4 +278,23 @@ mod tests {
"local-cache.whitelist"
);
}
+
+ #[test]
+ fn test_parse_memory_size_supports_java_and_rust_units() {
+ for unit in ["k", "kb", "kib", "kibibytes"] {
+ assert_eq!(parse_memory_size(&format!("2 {unit}")), Ok(2 * 1024));
+ }
+ assert_eq!(parse_memory_size("2 bytes"), Ok(2));
+ assert_eq!(parse_memory_size("2 MiB"), Ok(2 * 1024 * 1024));
+ assert_eq!(parse_memory_size("2 gib"), Ok(2 * 1024 * 1024 * 1024));
+ assert_eq!(parse_memory_size("2 TiB"), Ok(2 * 1024_i64.pow(4)));
+ assert_eq!(
+ parse_memory_size("2 unknown"),
+ Err(ParseMemorySizeError::Invalid)
+ );
+ assert_eq!(
+ parse_memory_size("9223372036854775807 tb"),
+ Err(ParseMemorySizeError::Overflow)
+ );
+ }
}
diff --git a/crates/paimon/src/io/cache/disk.rs
b/crates/paimon/src/io/cache/disk.rs
index e63a2b5d..bd19e93a 100644
--- a/crates/paimon/src/io/cache/disk.rs
+++ b/crates/paimon/src/io/cache/disk.rs
@@ -24,40 +24,15 @@ use std::path::{Path, PathBuf};
use std::sync::{Arc, Mutex, OnceLock, Weak};
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 CHECKSUM_LEN: usize = 4;
const MAX_CACHE_KEY_HEADER_LEN: usize = 1024 * 1024;
-#[derive(Clone, Debug, Eq, Hash, PartialEq)]
-pub(super) struct BlockKey {
- namespace: String,
- path: String,
- block_size: u64,
- block_index: u64,
-}
-
impl BlockKey {
- #[cfg(test)]
- pub(super) fn new(path: impl Into<String>, block_size: u64, block_index:
u64) -> Self {
- Self::with_namespace("", path, block_size, block_index)
- }
-
- pub(super) fn with_namespace(
- namespace: impl Into<String>,
- path: impl Into<String>,
- block_size: u64,
- block_index: u64,
- ) -> Self {
- Self {
- namespace: namespace.into(),
- path: path.into(),
- block_size,
- block_index,
- }
- }
-
pub(super) fn cache_relative_path(&self) -> PathBuf {
let mut digest = Sha256::new();
digest.update([CACHE_FORMAT_VERSION]);
@@ -86,51 +61,17 @@ pub(super) struct DiskCache {
root: PathBuf,
state: Mutex<CacheState>,
recovered: tokio::sync::OnceCell<()>,
- in_flight: tokio::sync::Mutex<HashMap<BlockKey,
Weak<tokio::sync::Mutex<()>>>>,
- path_states: Mutex<HashMap<LogicalPath, Weak<PathCacheState>>>,
- prefix_barrier: tokio::sync::RwLock<()>,
+ coordinator: Arc<CacheCoordinator>,
}
#[derive(Debug, Default)]
struct CacheState {
entries: IndexMap<BlockKey, u64>,
paths: HashMap<LogicalPath, HashSet<BlockKey>>,
- file_sizes: IndexMap<LogicalPath, u64>,
current_size: u64,
max_size: Option<u64>,
}
-#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
-struct LogicalPath {
- namespace: String,
- path: String,
-}
-
-#[derive(Debug)]
-struct PathCacheState {
- generation: std::sync::atomic::AtomicU64,
- publish_gate: tokio::sync::RwLock<()>,
-}
-
-#[derive(Clone)]
-pub(in crate::io) struct CacheReadToken {
- generation: u64,
- state: Arc<PathCacheState>,
-}
-
-impl CacheReadToken {
- pub(super) fn is_current(&self) -> bool {
- self.state
- .generation
- .load(std::sync::atomic::Ordering::SeqCst)
- == self.generation
- }
-
- pub(super) async fn publish_guard(&self) ->
tokio::sync::RwLockReadGuard<'_, ()> {
- self.state.publish_gate.read().await
- }
-}
-
impl DiskCache {
#[cfg(test)]
pub(super) fn new(root: impl AsRef<Path>, max_size: Option<u64>) ->
crate::Result<Self> {
@@ -167,9 +108,7 @@ impl DiskCache {
..CacheState::default()
}),
recovered: tokio::sync::OnceCell::new(),
- in_flight: tokio::sync::Mutex::new(HashMap::new()),
- path_states: Mutex::new(HashMap::new()),
- prefix_barrier: tokio::sync::RwLock::new(()),
+ coordinator: Arc::new(CacheCoordinator::default()),
}
}
@@ -182,7 +121,7 @@ impl DiskCache {
};
}
- async fn ensure_recovered(&self) {
+ pub(super) async fn ensure_recovered(&self) {
self.recovered
.get_or_init(|| async {
let root = self.root.clone();
@@ -218,92 +157,8 @@ impl DiskCache {
self.evict_over_limit().await;
}
- pub(super) fn read_token(&self, namespace: &str, path: &str) ->
CacheReadToken {
- let state = self.path_state(namespace, path);
- CacheReadToken {
- generation:
state.generation.load(std::sync::atomic::Ordering::SeqCst),
- state,
- }
- }
-
- pub(super) async fn prefix_read_guard(&self) ->
tokio::sync::RwLockReadGuard<'_, ()> {
- self.prefix_barrier.read().await
- }
-
- fn path_state(&self, namespace: &str, path: &str) -> Arc<PathCacheState> {
- let logical_path = LogicalPath {
- namespace: namespace.to_string(),
- path: path.to_string(),
- };
- let mut states = self
- .path_states
- .lock()
- .unwrap_or_else(|error| error.into_inner());
- if let Some(state) = states.get(&logical_path).and_then(Weak::upgrade)
{
- return state;
- }
- if states.len() >= 1024 {
- states.retain(|_, state| state.strong_count() > 0);
- }
- let state = Arc::new(PathCacheState {
- generation: std::sync::atomic::AtomicU64::new(0),
- publish_gate: tokio::sync::RwLock::new(()),
- });
- states.insert(logical_path, Arc::downgrade(&state));
- state
- }
-
- pub(super) async fn block_load_lock(&self, key: &BlockKey) ->
Arc<tokio::sync::Mutex<()>> {
- let mut in_flight = self.in_flight.lock().await;
- if let Some(lock) = in_flight.get(key).and_then(Weak::upgrade) {
- return lock;
- }
- if in_flight.len() >= 1024 {
- in_flight.retain(|_, lock| lock.strong_count() > 0);
- }
- let lock = Arc::new(tokio::sync::Mutex::new(()));
- in_flight.insert(key.clone(), Arc::downgrade(&lock));
- lock
- }
-
- pub(super) async fn release_block_load_lock(
- &self,
- key: &BlockKey,
- lock: &Arc<tokio::sync::Mutex<()>>,
- ) {
- let mut in_flight = self.in_flight.lock().await;
- if Arc::strong_count(lock) == 1
- && in_flight
- .get(key)
- .and_then(Weak::upgrade)
- .is_some_and(|current| Arc::ptr_eq(¤t, lock))
- {
- in_flight.remove(key);
- }
- }
-
- pub(super) fn file_size(&self, namespace: &str, path: &str) -> Option<u64>
{
- let logical_path = LogicalPath {
- namespace: namespace.to_string(),
- path: path.to_string(),
- };
- let mut state = self.state.lock().unwrap_or_else(|error|
error.into_inner());
- let size = state.file_sizes.shift_remove(&logical_path)?;
- state.file_sizes.insert(logical_path, size);
- Some(size)
- }
-
- pub(super) fn put_file_size(&self, namespace: &str, path: &str, size: u64,
capacity: usize) {
- let logical_path = LogicalPath {
- namespace: namespace.to_string(),
- path: path.to_string(),
- };
- let mut state = self.state.lock().unwrap_or_else(|error|
error.into_inner());
- state.file_sizes.shift_remove(&logical_path);
- state.file_sizes.insert(logical_path, size);
- while state.file_sizes.len() > capacity {
- state.file_sizes.shift_remove_index(0);
- }
+ pub(super) fn coordinator(&self) -> Arc<CacheCoordinator> {
+ self.coordinator.clone()
}
pub(super) async fn get_block(&self, key: &BlockKey) -> Option<Bytes> {
@@ -411,12 +266,6 @@ impl DiskCache {
pub(super) async fn invalidate_path(&self, namespace: &str, path: &str) {
self.ensure_recovered().await;
- let _prefix_guard = self.prefix_barrier.read().await;
- let path_state = self.path_state(namespace, path);
- let _publish_guard = path_state.publish_gate.write().await;
- path_state
- .generation
- .fetch_add(1, std::sync::atomic::Ordering::SeqCst);
self.invalidate_matching(|logical_path| {
logical_path.namespace == namespace && logical_path.path == path
})
@@ -439,30 +288,9 @@ impl DiskCache {
pub(super) async fn invalidate_prefix(&self, namespace: &str, prefix:
&str) {
self.ensure_recovered().await;
- let _prefix_guard = self.prefix_barrier.write().await;
let prefix = prefix.trim_end_matches('/');
- let mut states = {
- let states = self
- .path_states
- .lock()
- .unwrap_or_else(|error| error.into_inner());
- states
- .iter()
- .filter(|(path, _)| logical_path_matches_prefix(path,
namespace, prefix))
- .filter_map(|(path, state)| Weak::upgrade(state).map(|state|
(path.clone(), state)))
- .collect::<Vec<_>>()
- };
- states.sort_unstable_by(|left, right| left.0.cmp(&right.0));
- let mut publish_guards = Vec::with_capacity(states.len());
- for (_, state) in &states {
- publish_guards.push(state.publish_gate.write().await);
- state
- .generation
- .fetch_add(1, std::sync::atomic::Ordering::SeqCst);
- }
self.invalidate_matching(|path| logical_path_matches_prefix(path,
namespace, prefix))
.await;
- drop(publish_guards);
}
async fn invalidate_matching(&self, matches: impl Fn(&LogicalPath) ->
bool) {
@@ -478,7 +306,6 @@ impl DiskCache {
for key in &keys {
remove_state_entry(&mut state, key);
}
- state.file_sizes.retain(|path, _| !matches(path));
keys
};
for key in keys {
@@ -579,10 +406,7 @@ fn collect_evictions(state: &mut CacheState) ->
Vec<BlockKey> {
}
fn logical_path(key: &BlockKey) -> LogicalPath {
- LogicalPath {
- namespace: key.namespace.clone(),
- path: key.path.clone(),
- }
+ LogicalPath::from_key(key)
}
fn logical_path_matches_prefix(path: &LogicalPath, namespace: &str, prefix:
&str) -> bool {
diff --git a/crates/paimon/src/io/cache/memory.rs
b/crates/paimon/src/io/cache/memory.rs
new file mode 100644
index 00000000..c50cd91a
--- /dev/null
+++ b/crates/paimon/src/io/cache/memory.rs
@@ -0,0 +1,139 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements. See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership. The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License. You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied. See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+use bytes::Bytes;
+use lru::LruCache;
+use std::sync::Mutex;
+
+use super::state::BlockKey;
+
+#[derive(Debug)]
+pub(super) struct MemoryCache {
+ state: Mutex<MemoryState>,
+}
+
+#[derive(Debug)]
+struct MemoryState {
+ entries: LruCache<BlockKey, Bytes>,
+ current_size: u64,
+ max_size: Option<u64>,
+}
+
+impl MemoryCache {
+ pub(super) fn new(max_size: Option<u64>) -> Self {
+ Self {
+ state: Mutex::new(MemoryState {
+ entries: LruCache::unbounded(),
+ current_size: 0,
+ max_size,
+ }),
+ }
+ }
+
+ pub(super) fn get_block(&self, key: &BlockKey) -> Option<Bytes> {
+ let mut state = self.state.lock().unwrap_or_else(|error|
error.into_inner());
+ state.entries.get(key).cloned()
+ }
+
+ pub(super) fn put_block(&self, key: &BlockKey, payload: Bytes) {
+ let payload_size = payload.len() as u64;
+ let mut state = self.state.lock().unwrap_or_else(|error|
error.into_inner());
+ if state
+ .max_size
+ .is_some_and(|max_size| payload_size > max_size)
+ {
+ 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_add(payload_size);
+ while state
+ .max_size
+ .is_some_and(|max_size| state.current_size > max_size)
+ {
+ let Some((_, payload)) = state.entries.pop_lru() else {
+ break;
+ };
+ 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(payload.len() as u64);
+ }
+ }
+
+ pub(super) fn invalidate_path(&self, namespace: &str, path: &str) {
+ self.invalidate_matching(|key| key.matches_path(namespace, path));
+ }
+
+ pub(super) fn invalidate_prefix(&self, namespace: &str, prefix: &str) {
+ let prefix = prefix.trim_end_matches('/');
+ self.invalidate_matching(|key| key.matches_prefix(namespace, prefix));
+ }
+
+ fn invalidate_matching(&self, matches: impl Fn(&BlockKey) -> bool) {
+ let mut state = self.state.lock().unwrap_or_else(|error|
error.into_inner());
+ let keys = state
+ .entries
+ .iter()
+ .map(|(key, _)| key)
+ .filter(|key| matches(key))
+ .cloned()
+ .collect::<Vec<_>>();
+ for key in keys {
+ if let Some(payload) = state.entries.pop(&key) {
+ state.current_size =
state.current_size.saturating_sub(payload.len() as u64);
+ }
+ }
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn test_memory_cache_refreshes_lru_and_evicts_by_payload_bytes() {
+ let cache = MemoryCache::new(Some(8));
+ let first = BlockKey::new("snapshot-1", 4, 0);
+ let second = BlockKey::new("snapshot-1", 4, 1);
+ let third = BlockKey::new("snapshot-1", 4, 2);
+
+ cache.put_block(&first, Bytes::from_static(b"aaaa"));
+ cache.put_block(&second, Bytes::from_static(b"bbbb"));
+ assert_eq!(cache.get_block(&first), Some(Bytes::from_static(b"aaaa")));
+ cache.put_block(&third, Bytes::from_static(b"cccc"));
+
+ assert_eq!(cache.get_block(&second), None);
+ assert_eq!(cache.get_block(&first), Some(Bytes::from_static(b"aaaa")));
+ assert_eq!(cache.get_block(&third), Some(Bytes::from_static(b"cccc")));
+ }
+
+ #[test]
+ fn test_memory_cache_skips_block_larger_than_capacity() {
+ let cache = MemoryCache::new(Some(3));
+ let key = BlockKey::new("snapshot-1", 4, 0);
+
+ cache.put_block(&key, Bytes::from_static(b"data"));
+
+ assert_eq!(cache.get_block(&key), None);
+ }
+}
diff --git a/crates/paimon/src/io/cache/mod.rs
b/crates/paimon/src/io/cache/mod.rs
index 66792c53..9560394b 100644
--- a/crates/paimon/src/io/cache/mod.rs
+++ b/crates/paimon/src/io/cache/mod.rs
@@ -17,16 +17,20 @@
mod disk;
mod file_type;
+mod memory;
mod reader;
+mod state;
use self::file_type::FileType;
+use self::memory::MemoryCache;
+use self::state::{BlockKey, CacheCoordinator, CacheReadToken};
use crate::common::{CatalogOptions, Options};
use sha2::{Digest, Sha256};
use std::collections::HashSet;
use std::path::PathBuf;
use std::sync::Arc;
-use disk::{BlockKey, CacheReadToken, DiskCache};
+use disk::DiskCache;
pub(super) use reader::CachedFileReader;
const CACHE_DIRECTORY_NAME: &str = "paimon-local-cache-v2";
@@ -34,13 +38,20 @@ const DEFAULT_FILE_SIZE_CAPACITY: usize = 65_536;
#[derive(Debug)]
pub(crate) struct LocalCache {
- disk: Arc<DiskCache>,
+ backend: CacheBackend,
+ coordinator: Arc<CacheCoordinator>,
namespace: String,
block_size: u64,
whitelist: HashSet<FileType>,
file_size_capacity: usize,
}
+#[derive(Debug)]
+enum CacheBackend {
+ Memory(MemoryCache),
+ Disk(Arc<DiskCache>),
+}
+
impl LocalCache {
pub(super) fn new(config: LocalCacheConfig) -> crate::Result<Self> {
let file_size_capacity = config
@@ -49,8 +60,19 @@ impl LocalCache {
.and_then(|capacity| usize::try_from(capacity).ok())
.unwrap_or(DEFAULT_FILE_SIZE_CAPACITY)
.clamp(1, DEFAULT_FILE_SIZE_CAPACITY);
+ let (backend, coordinator) = if let Some(dir) = config.dir {
+ let disk = DiskCache::shared(dir.join(CACHE_DIRECTORY_NAME),
config.max_size)?;
+ let coordinator = disk.coordinator();
+ (CacheBackend::Disk(disk), coordinator)
+ } else {
+ (
+ CacheBackend::Memory(MemoryCache::new(config.max_size)),
+ Arc::new(CacheCoordinator::default()),
+ )
+ };
Ok(Self {
- disk: DiskCache::shared(config.dir.join(CACHE_DIRECTORY_NAME),
config.max_size)?,
+ backend,
+ coordinator,
namespace: config.namespace,
block_size: config.block_size,
whitelist: config.whitelist,
@@ -71,12 +93,15 @@ impl LocalCache {
}
async fn get_block(&self, key: &BlockKey, token: &CacheReadToken) ->
Option<bytes::Bytes> {
- let _prefix_guard = self.disk.prefix_read_guard().await;
+ let _prefix_guard = self.coordinator.prefix_read_guard().await;
let _publish_guard = token.publish_guard().await;
if !token.is_current() {
return None;
}
- let payload = self.disk.get_block(key).await;
+ let payload = match &self.backend {
+ CacheBackend::Memory(memory) => memory.get_block(key),
+ CacheBackend::Disk(disk) => disk.get_block(key).await,
+ };
if token.is_current() {
payload
} else {
@@ -85,55 +110,78 @@ impl LocalCache {
}
async fn put_block(&self, key: &BlockKey, payload: bytes::Bytes, token:
&CacheReadToken) {
- let _prefix_guard = self.disk.prefix_read_guard().await;
+ let _prefix_guard = self.coordinator.prefix_read_guard().await;
let _publish_guard = token.publish_guard().await;
if !token.is_current() {
return;
}
- self.disk.put_block(key, payload).await;
+ match &self.backend {
+ CacheBackend::Memory(memory) => memory.put_block(key, payload),
+ CacheBackend::Disk(disk) => disk.put_block(key, payload).await,
+ }
}
async fn remove_block(&self, key: &BlockKey) {
- self.disk.remove_block(key).await;
+ match &self.backend {
+ CacheBackend::Memory(memory) => memory.remove_block(key),
+ CacheBackend::Disk(disk) => disk.remove_block(key).await,
+ }
}
pub(super) fn read_token(&self, path: &str) -> CacheReadToken {
- self.disk.read_token(&self.namespace, path)
+ self.coordinator.read_token(&self.namespace, path)
}
async fn block_load_lock(&self, key: &BlockKey) ->
Arc<tokio::sync::Mutex<()>> {
- self.disk.block_load_lock(key).await
+ self.coordinator.block_load_lock(key).await
}
async fn release_block_load_lock(&self, key: &BlockKey, lock:
&Arc<tokio::sync::Mutex<()>>) {
- self.disk.release_block_load_lock(key, lock).await;
+ self.coordinator.release_block_load_lock(key, lock).await;
}
pub(super) async fn file_size(&self, path: &str, token: &CacheReadToken)
-> Option<u64> {
- let _prefix_guard = self.disk.prefix_read_guard().await;
+ let _prefix_guard = self.coordinator.prefix_read_guard().await;
let _publish_guard = token.publish_guard().await;
token
.is_current()
- .then(|| self.disk.file_size(&self.namespace, path))
+ .then(|| self.coordinator.file_size(&self.namespace, path))
.flatten()
}
pub(super) async fn put_file_size(&self, path: &str, size: u64, token:
&CacheReadToken) {
- let _prefix_guard = self.disk.prefix_read_guard().await;
+ let _prefix_guard = self.coordinator.prefix_read_guard().await;
let _publish_guard = token.publish_guard().await;
if !token.is_current() {
return;
}
- self.disk
+ self.coordinator
.put_file_size(&self.namespace, path, size,
self.file_size_capacity);
}
pub(super) async fn invalidate_path(&self, path: &str) {
- self.disk.invalidate_path(&self.namespace, path).await;
+ let _guard = self
+ .coordinator
+ .begin_path_invalidation(&self.namespace, path)
+ .await;
+ match &self.backend {
+ CacheBackend::Memory(memory) =>
memory.invalidate_path(&self.namespace, path),
+ CacheBackend::Disk(disk) => disk.invalidate_path(&self.namespace,
path).await,
+ }
}
pub(super) async fn invalidate_prefix(&self, prefix: &str) {
- self.disk.invalidate_prefix(&self.namespace, prefix).await;
+ if let CacheBackend::Disk(disk) = &self.backend {
+ disk.ensure_recovered().await;
+ }
+ let _guard = self
+ .coordinator
+ .begin_prefix_invalidation(&self.namespace, prefix)
+ .await;
+ match &self.backend {
+ CacheBackend::Memory(memory) =>
memory.invalidate_prefix(&self.namespace, prefix),
+ CacheBackend::Disk(disk) =>
disk.invalidate_prefix(&self.namespace, prefix).await,
+ }
}
}
@@ -153,7 +201,7 @@ pub(crate) fn create_local_cache_with_namespace(
#[derive(Debug)]
pub(crate) struct LocalCacheConfig {
- dir: PathBuf,
+ dir: Option<PathBuf>,
namespace: String,
max_size: Option<u64>,
block_size: u64,
@@ -194,13 +242,7 @@ impl LocalCacheConfig {
let dir = options
.get(CatalogOptions::LOCAL_CACHE_DIR)
.filter(|value| !value.trim().is_empty())
- .ok_or_else(|| crate::Error::ConfigInvalid {
- message: format!(
- "Missing required option: {}",
- CatalogOptions::LOCAL_CACHE_DIR
- ),
- })?
- .into();
+ .map(PathBuf::from);
let max_size = options
.get(CatalogOptions::LOCAL_CACHE_MAX_SIZE)
@@ -252,36 +294,17 @@ fn catalog_namespace(options: &Options) -> String {
}
fn parse_memory_size(key: &str, value: &str) -> crate::Result<u64> {
- let compact = value
- .chars()
- .filter(|character| !character.is_ascii_whitespace())
- .collect::<String>()
- .to_ascii_lowercase();
- let unit_start = compact
- .find(|character: char| !character.is_ascii_digit())
- .unwrap_or(compact.len());
- let (number, unit) = compact.split_at(unit_start);
- let number = number
- .parse::<u64>()
- .map_err(|_| crate::Error::ConfigInvalid {
- message: format!("Invalid memory size for {key}: '{value}'"),
- })?;
- let multiplier = match unit {
- "" | "b" => 1,
- "k" | "kb" | "kib" => 1024,
- "m" | "mb" | "mib" => 1024 * 1024,
- "g" | "gb" | "gib" => 1024 * 1024 * 1024,
- "t" | "tb" | "tib" => 1024_u64.pow(4),
- _ => {
- return Err(crate::Error::ConfigInvalid {
- message: format!("Invalid memory size for {key}: '{value}'"),
- });
- }
- };
- number
- .checked_mul(multiplier)
- .ok_or_else(|| crate::Error::ConfigInvalid {
- message: format!("Memory size for {key} is too large: '{value}'"),
+ crate::common::options::parse_memory_size(value)
+ .map(|size| size as u64)
+ .map_err(|error| crate::Error::ConfigInvalid {
+ message: match error {
+ crate::common::options::ParseMemorySizeError::Invalid => {
+ format!("Invalid memory size for {key}: '{value}'")
+ }
+ crate::common::options::ParseMemorySizeError::Overflow => {
+ format!("Memory size for {key} is too large: '{value}'")
+ }
+ },
})
}
@@ -297,13 +320,19 @@ mod tests {
}
#[test]
- fn test_local_cache_config_requires_directory_when_enabled() {
+ fn test_local_cache_config_uses_memory_when_enabled_without_directory() {
let mut options = Options::new();
options.set(crate::common::CatalogOptions::LOCAL_CACHE_ENABLED,
"true");
- let error = LocalCacheConfig::from_options(&options).unwrap_err();
- assert!(matches!(error, crate::Error::ConfigInvalid { .. }));
- assert!(error.to_string().contains("local-cache.dir"));
+ let config =
LocalCacheConfig::from_options(&options).unwrap().unwrap();
+
+ assert_eq!(config.dir, None);
+ assert_eq!(config.max_size, None);
+ assert_eq!(config.block_size, 1024 * 1024);
+ assert_eq!(
+ config.whitelist,
+ HashSet::from([FileType::Meta, FileType::GlobalIndex])
+ );
}
#[test]
@@ -313,7 +342,10 @@ mod tests {
options.set(CatalogOptions::LOCAL_CACHE_DIR, "/tmp/paimon-cache");
let config =
LocalCacheConfig::from_options(&options).unwrap().unwrap();
- assert_eq!(config.dir, std::path::Path::new("/tmp/paimon-cache"));
+ assert_eq!(
+ config.dir.as_deref(),
+ Some(std::path::Path::new("/tmp/paimon-cache"))
+ );
assert_eq!(config.max_size, None);
assert_eq!(config.block_size, 1024 * 1024);
assert_eq!(
@@ -359,8 +391,8 @@ mod tests {
.unwrap();
assert_eq!(
- effective_config.dir,
- std::path::Path::new("/tmp/paimon-cache")
+ effective_config.dir.as_deref(),
+ Some(std::path::Path::new("/tmp/paimon-cache"))
);
assert_ne!(effective_config.namespace, local_namespace);
assert_eq!(
@@ -395,7 +427,7 @@ mod tests {
async fn test_local_cache_file_size_is_removed_with_path_invalidation() {
let directory = tempfile::tempdir().unwrap();
let cache = LocalCache::new(LocalCacheConfig {
- dir: directory.path().to_path_buf(),
+ dir: Some(directory.path().to_path_buf()),
namespace: "test".to_string(),
max_size: None,
block_size: 4,
@@ -416,7 +448,7 @@ mod tests {
async fn
test_local_cache_file_size_is_invalidated_across_shared_instances() {
let directory = tempfile::tempdir().unwrap();
let config = || LocalCacheConfig {
- dir: directory.path().to_path_buf(),
+ dir: Some(directory.path().to_path_buf()),
namespace: "test".to_string(),
max_size: None,
block_size: 4,
@@ -437,30 +469,31 @@ mod tests {
#[tokio::test]
async fn test_stale_file_size_cannot_republish_after_invalidation() {
let directory = tempfile::tempdir().unwrap();
- let cache = LocalCache::new(LocalCacheConfig {
- dir: directory.path().to_path_buf(),
+ let config = || LocalCacheConfig {
+ dir: Some(directory.path().to_path_buf()),
namespace: "test".to_string(),
max_size: None,
block_size: 4,
whitelist: HashSet::from([FileType::Meta]),
- })
- .unwrap();
+ };
+ let first = LocalCache::new(config()).unwrap();
+ let second = LocalCache::new(config()).unwrap();
let path = "s3://bucket/table/snapshot/snapshot-1";
- let stale_token = cache.read_token(path);
+ let stale_token = first.read_token(path);
- assert_eq!(cache.file_size(path, &stale_token).await, None);
- cache.invalidate_path(path).await;
- cache.put_file_size(path, 42, &stale_token).await;
+ assert_eq!(first.file_size(path, &stale_token).await, None);
+ second.invalidate_path(path).await;
+ first.put_file_size(path, 42, &stale_token).await;
- let current_token = cache.read_token(path);
- assert_eq!(cache.file_size(path, ¤t_token).await, None);
+ let current_token = first.read_token(path);
+ assert_eq!(first.file_size(path, ¤t_token).await, None);
}
#[test]
fn test_local_cache_uses_whitelist_and_bypasses_mutable_files() {
let directory = tempfile::tempdir().unwrap();
let cache = LocalCache::new(LocalCacheConfig {
- dir: directory.path().to_path_buf(),
+ dir: Some(directory.path().to_path_buf()),
namespace: "test".to_string(),
max_size: None,
block_size: 4,
@@ -487,7 +520,7 @@ mod tests {
std::fs::write(&nested, b"nested foreign").unwrap();
LocalCache::new(LocalCacheConfig {
- dir: directory.path().to_path_buf(),
+ dir: Some(directory.path().to_path_buf()),
namespace: "test".to_string(),
max_size: None,
block_size: 4,
@@ -507,7 +540,7 @@ mod tests {
async fn test_local_cache_bounds_file_size_entries() {
let directory = tempfile::tempdir().unwrap();
let cache = LocalCache::new(LocalCacheConfig {
- dir: directory.path().to_path_buf(),
+ dir: Some(directory.path().to_path_buf()),
namespace: "test".to_string(),
max_size: Some(8),
block_size: 4,
diff --git a/crates/paimon/src/io/cache/reader.rs
b/crates/paimon/src/io/cache/reader.rs
index 40aeb728..72a3b700 100644
--- a/crates/paimon/src/io/cache/reader.rs
+++ b/crates/paimon/src/io/cache/reader.rs
@@ -253,14 +253,13 @@ mod tests {
#[tokio::test]
async fn test_cached_range_reader_reads_unaligned_blocks_and_reuses_them()
{
- let directory = tempfile::tempdir().unwrap();
let delegate = Arc::new(CountingReader {
data: Bytes::from_static(b"abcdefghijkl"),
reads: AtomicUsize::new(0),
});
let cache = Arc::new(
LocalCache::new(LocalCacheConfig {
- dir: directory.path().to_path_buf(),
+ dir: None,
namespace: "test".to_string(),
max_size: None,
block_size: 4,
@@ -304,14 +303,13 @@ mod tests {
#[tokio::test]
async fn test_cached_range_single_flight_reads_cold_block_once() {
- let directory = tempfile::tempdir().unwrap();
let delegate = Arc::new(SlowCountingReader {
data: Bytes::from_static(b"abcdefgh"),
reads: AtomicUsize::new(0),
});
let cache = Arc::new(
LocalCache::new(LocalCacheConfig {
- dir: directory.path().to_path_buf(),
+ dir: None,
namespace: "test".to_string(),
max_size: None,
block_size: 4,
@@ -341,7 +339,7 @@ mod tests {
reads: AtomicUsize::new(0),
});
let config = || LocalCacheConfig {
- dir: directory.path().to_path_buf(),
+ dir: Some(directory.path().to_path_buf()),
namespace: "test".to_string(),
max_size: None,
block_size: 4,
@@ -385,11 +383,10 @@ mod tests {
#[tokio::test]
async fn test_in_flight_miss_does_not_republish_after_invalidation() {
- let directory = tempfile::tempdir().unwrap();
let path = "s3://bucket/table/snapshot/snapshot-1";
let cache = Arc::new(
LocalCache::new(LocalCacheConfig {
- dir: directory.path().to_path_buf(),
+ dir: None,
namespace: "test".to_string(),
max_size: None,
block_size: 4,
@@ -438,7 +435,7 @@ mod tests {
let path = "s3://bucket/table/snapshot/snapshot-1";
let cache_a = Arc::new(
LocalCache::new(LocalCacheConfig {
- dir: directory.path().to_path_buf(),
+ dir: Some(directory.path().to_path_buf()),
namespace: "test".to_string(),
max_size: None,
block_size: 4,
@@ -448,7 +445,7 @@ mod tests {
);
let cache_b = Arc::new(
LocalCache::new(LocalCacheConfig {
- dir: directory.path().to_path_buf(),
+ dir: Some(directory.path().to_path_buf()),
namespace: "test".to_string(),
max_size: None,
block_size: 4,
@@ -498,16 +495,15 @@ mod tests {
let first_path = "s3://bucket/table/snapshot/snapshot-1";
let observed_path = "s3://bucket/table/snapshot/snapshot-2";
let blocked_path = "s3://bucket/table/snapshot/snapshot-3";
- let cache = Arc::new(
- LocalCache::new(LocalCacheConfig {
- dir: directory.path().to_path_buf(),
- namespace: "test".to_string(),
- max_size: None,
- block_size: 4,
- whitelist: std::collections::HashSet::from([FileType::Meta]),
- })
- .unwrap(),
- );
+ let config = || LocalCacheConfig {
+ dir: Some(directory.path().to_path_buf()),
+ namespace: "test".to_string(),
+ max_size: None,
+ block_size: 4,
+ whitelist: std::collections::HashSet::from([FileType::Meta]),
+ };
+ let cache_a = Arc::new(LocalCache::new(config()).unwrap());
+ let cache_b = Arc::new(LocalCache::new(config()).unwrap());
let old_reader = CachedFileReader::new(
Arc::new(CountingReader {
data: Bytes::from_static(b"old!"),
@@ -515,21 +511,21 @@ mod tests {
}),
first_path,
4,
- cache.clone(),
+ cache_a.clone(),
);
assert_eq!(
old_reader.read(0..4).await.unwrap(),
Bytes::from_static(b"old!")
);
- let warm_size_token = cache.read_token(first_path);
- cache.put_file_size(first_path, 4, &warm_size_token).await;
+ let warm_size_token = cache_a.read_token(first_path);
+ cache_a.put_file_size(first_path, 4, &warm_size_token).await;
drop(warm_size_token);
drop(old_reader);
- let observed_token = cache.read_token(observed_path);
- let blocked_token = cache.read_token(blocked_path);
+ let observed_token = cache_a.read_token(observed_path);
+ let blocked_token = cache_a.read_token(blocked_path);
let blocked_guard = blocked_token.publish_guard().await;
- let invalidating_cache = cache.clone();
+ let invalidating_cache = cache_b;
let invalidation =
tokio::spawn(async move {
invalidating_cache.invalidate_prefix(prefix).await });
tokio::time::timeout(std::time::Duration::from_secs(1), async {
@@ -545,9 +541,9 @@ mod tests {
reads: AtomicUsize::new(0),
});
let current_reader =
- CachedFileReader::new(current_delegate.clone(), first_path, 4,
cache.clone());
+ CachedFileReader::new(current_delegate.clone(), first_path, 4,
cache_a.clone());
let mut current_load = tokio::spawn(async move {
current_reader.read(0..4).await });
- let size_cache = cache.clone();
+ let size_cache = cache_a;
let mut current_size = tokio::spawn(async move {
let token = size_cache.read_token(first_path);
size_cache.file_size(first_path, &token).await
@@ -646,7 +642,7 @@ mod tests {
});
let cache = Arc::new(
LocalCache::new(LocalCacheConfig {
- dir: directory.path().to_path_buf(),
+ dir: Some(directory.path().to_path_buf()),
namespace: "test".to_string(),
max_size: None,
block_size: 4,
@@ -680,7 +676,7 @@ mod tests {
let directory = tempfile::tempdir().unwrap();
let cache = Arc::new(
LocalCache::new(LocalCacheConfig {
- dir: directory.path().to_path_buf(),
+ dir: Some(directory.path().to_path_buf()),
namespace: "test".to_string(),
max_size: None,
block_size: 4,
@@ -695,14 +691,13 @@ mod tests {
#[tokio::test]
async fn test_cached_full_reader_loads_source_once_then_hits_blocks() {
- let directory = tempfile::tempdir().unwrap();
let delegate = Arc::new(CountingReader {
data: Bytes::from_static(b"abcdefghijkl"),
reads: AtomicUsize::new(0),
});
let cache = Arc::new(
LocalCache::new(LocalCacheConfig {
- dir: directory.path().to_path_buf(),
+ dir: None,
namespace: "test".to_string(),
max_size: None,
block_size: 4,
diff --git a/crates/paimon/src/io/cache/state.rs
b/crates/paimon/src/io/cache/state.rs
new file mode 100644
index 00000000..7625186b
--- /dev/null
+++ b/crates/paimon/src/io/cache/state.rs
@@ -0,0 +1,295 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements. See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership. The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License. You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied. See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+use indexmap::IndexMap;
+use std::collections::HashMap;
+use std::sync::{Arc, Mutex, Weak};
+
+#[derive(Clone, Debug, Eq, Hash, PartialEq)]
+pub(super) struct BlockKey {
+ pub(super) namespace: String,
+ pub(super) path: String,
+ pub(super) block_size: u64,
+ pub(super) block_index: u64,
+}
+
+impl BlockKey {
+ #[cfg(test)]
+ pub(super) fn new(path: impl Into<String>, block_size: u64, block_index:
u64) -> Self {
+ Self::with_namespace("", path, block_size, block_index)
+ }
+
+ pub(super) fn with_namespace(
+ namespace: impl Into<String>,
+ path: impl Into<String>,
+ block_size: u64,
+ block_index: u64,
+ ) -> Self {
+ Self {
+ namespace: namespace.into(),
+ path: path.into(),
+ block_size,
+ block_index,
+ }
+ }
+
+ pub(super) fn matches_path(&self, namespace: &str, path: &str) -> bool {
+ self.namespace == namespace && self.path == path
+ }
+
+ pub(super) fn matches_prefix(&self, namespace: &str, prefix: &str) -> bool
{
+ self.namespace == namespace && path_matches_prefix(&self.path, prefix)
+ }
+}
+
+#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
+pub(super) struct LogicalPath {
+ pub(super) namespace: String,
+ pub(super) path: String,
+}
+
+impl LogicalPath {
+ pub(super) fn new(namespace: &str, path: &str) -> Self {
+ Self {
+ namespace: namespace.to_string(),
+ path: path.to_string(),
+ }
+ }
+
+ pub(super) fn from_key(key: &BlockKey) -> Self {
+ Self::new(&key.namespace, &key.path)
+ }
+
+ fn matches_prefix(&self, namespace: &str, prefix: &str) -> bool {
+ self.namespace == namespace && path_matches_prefix(&self.path, prefix)
+ }
+}
+
+fn path_matches_prefix(path: &str, prefix: &str) -> bool {
+ path == prefix
+ || path
+ .strip_prefix(prefix)
+ .is_some_and(|suffix| suffix.starts_with('/'))
+}
+
+#[derive(Debug)]
+struct PathCacheState {
+ generation: std::sync::atomic::AtomicU64,
+ publish_gate: Arc<tokio::sync::RwLock<()>>,
+}
+
+#[derive(Clone)]
+pub(in crate::io) struct CacheReadToken {
+ generation: u64,
+ state: Arc<PathCacheState>,
+}
+
+impl CacheReadToken {
+ pub(super) fn is_current(&self) -> bool {
+ self.state
+ .generation
+ .load(std::sync::atomic::Ordering::SeqCst)
+ == self.generation
+ }
+
+ pub(super) async fn publish_guard(&self) ->
tokio::sync::RwLockReadGuard<'_, ()> {
+ self.state.publish_gate.read().await
+ }
+}
+
+#[derive(Debug)]
+pub(super) struct CacheCoordinator {
+ in_flight: tokio::sync::Mutex<HashMap<BlockKey,
Weak<tokio::sync::Mutex<()>>>>,
+ path_states: Mutex<HashMap<LogicalPath, Weak<PathCacheState>>>,
+ prefix_barrier: Arc<tokio::sync::RwLock<()>>,
+ file_sizes: Mutex<IndexMap<LogicalPath, u64>>,
+}
+
+impl Default for CacheCoordinator {
+ fn default() -> Self {
+ Self {
+ in_flight: tokio::sync::Mutex::new(HashMap::new()),
+ path_states: Mutex::new(HashMap::new()),
+ prefix_barrier: Arc::new(tokio::sync::RwLock::new(())),
+ file_sizes: Mutex::new(IndexMap::new()),
+ }
+ }
+}
+
+impl CacheCoordinator {
+ pub(super) fn read_token(&self, namespace: &str, path: &str) ->
CacheReadToken {
+ let state = self.path_state(namespace, path);
+ CacheReadToken {
+ generation:
state.generation.load(std::sync::atomic::Ordering::SeqCst),
+ state,
+ }
+ }
+
+ pub(super) async fn prefix_read_guard(&self) ->
tokio::sync::RwLockReadGuard<'_, ()> {
+ self.prefix_barrier.read().await
+ }
+
+ fn path_state(&self, namespace: &str, path: &str) -> Arc<PathCacheState> {
+ let logical_path = LogicalPath::new(namespace, path);
+ let mut states = self
+ .path_states
+ .lock()
+ .unwrap_or_else(|error| error.into_inner());
+ if let Some(state) = states.get(&logical_path).and_then(Weak::upgrade)
{
+ return state;
+ }
+ if states.len() >= 1024 {
+ states.retain(|_, state| state.strong_count() > 0);
+ }
+ let state = Arc::new(PathCacheState {
+ generation: std::sync::atomic::AtomicU64::new(0),
+ publish_gate: Arc::new(tokio::sync::RwLock::new(())),
+ });
+ states.insert(logical_path, Arc::downgrade(&state));
+ state
+ }
+
+ pub(super) async fn block_load_lock(&self, key: &BlockKey) ->
Arc<tokio::sync::Mutex<()>> {
+ let mut in_flight = self.in_flight.lock().await;
+ if let Some(lock) = in_flight.get(key).and_then(Weak::upgrade) {
+ return lock;
+ }
+ if in_flight.len() >= 1024 {
+ in_flight.retain(|_, lock| lock.strong_count() > 0);
+ }
+ let lock = Arc::new(tokio::sync::Mutex::new(()));
+ in_flight.insert(key.clone(), Arc::downgrade(&lock));
+ lock
+ }
+
+ pub(super) async fn release_block_load_lock(
+ &self,
+ key: &BlockKey,
+ lock: &Arc<tokio::sync::Mutex<()>>,
+ ) {
+ let mut in_flight = self.in_flight.lock().await;
+ if Arc::strong_count(lock) == 1
+ && in_flight
+ .get(key)
+ .and_then(Weak::upgrade)
+ .is_some_and(|current| Arc::ptr_eq(¤t, lock))
+ {
+ in_flight.remove(key);
+ }
+ }
+
+ pub(super) fn file_size(&self, namespace: &str, path: &str) -> Option<u64>
{
+ let logical_path = LogicalPath::new(namespace, path);
+ let mut file_sizes = self
+ .file_sizes
+ .lock()
+ .unwrap_or_else(|error| error.into_inner());
+ let size = file_sizes.shift_remove(&logical_path)?;
+ file_sizes.insert(logical_path, size);
+ Some(size)
+ }
+
+ pub(super) fn put_file_size(&self, namespace: &str, path: &str, size: u64,
capacity: usize) {
+ let logical_path = LogicalPath::new(namespace, path);
+ let mut file_sizes = self
+ .file_sizes
+ .lock()
+ .unwrap_or_else(|error| error.into_inner());
+ file_sizes.shift_remove(&logical_path);
+ file_sizes.insert(logical_path, size);
+ while file_sizes.len() > capacity {
+ file_sizes.shift_remove_index(0);
+ }
+ }
+
+ pub(super) async fn begin_path_invalidation(
+ &self,
+ namespace: &str,
+ path: &str,
+ ) -> CacheInvalidationGuard {
+ let prefix_guard = PrefixGuard::Read {
+ _guard: self.prefix_barrier.clone().read_owned().await,
+ };
+ let state = self.path_state(namespace, path);
+ let publish_guard = state.publish_gate.clone().write_owned().await;
+ state
+ .generation
+ .fetch_add(1, std::sync::atomic::Ordering::SeqCst);
+ self.file_sizes
+ .lock()
+ .unwrap_or_else(|error| error.into_inner())
+ .shift_remove(&LogicalPath::new(namespace, path));
+ CacheInvalidationGuard {
+ _prefix_guard: prefix_guard,
+ _publish_guards: vec![publish_guard],
+ }
+ }
+
+ pub(super) async fn begin_prefix_invalidation(
+ &self,
+ namespace: &str,
+ prefix: &str,
+ ) -> CacheInvalidationGuard {
+ let prefix_guard = PrefixGuard::Write {
+ _guard: self.prefix_barrier.clone().write_owned().await,
+ };
+ let prefix = prefix.trim_end_matches('/');
+ let mut states = {
+ let states = self
+ .path_states
+ .lock()
+ .unwrap_or_else(|error| error.into_inner());
+ states
+ .iter()
+ .filter(|(path, _)| path.matches_prefix(namespace, prefix))
+ .filter_map(|(path, state)| Weak::upgrade(state).map(|state|
(path.clone(), state)))
+ .collect::<Vec<_>>()
+ };
+ states.sort_unstable_by(|left, right| left.0.cmp(&right.0));
+ let mut publish_guards = Vec::with_capacity(states.len());
+ for (_, state) in states {
+
publish_guards.push(state.publish_gate.clone().write_owned().await);
+ state
+ .generation
+ .fetch_add(1, std::sync::atomic::Ordering::SeqCst);
+ }
+ self.file_sizes
+ .lock()
+ .unwrap_or_else(|error| error.into_inner())
+ .retain(|path, _| !path.matches_prefix(namespace, prefix));
+ CacheInvalidationGuard {
+ _prefix_guard: prefix_guard,
+ _publish_guards: publish_guards,
+ }
+ }
+}
+
+#[derive(Debug)]
+enum PrefixGuard {
+ Read {
+ _guard: tokio::sync::OwnedRwLockReadGuard<()>,
+ },
+ Write {
+ _guard: tokio::sync::OwnedRwLockWriteGuard<()>,
+ },
+}
+
+#[derive(Debug)]
+pub(super) struct CacheInvalidationGuard {
+ _prefix_guard: PrefixGuard,
+ _publish_guards: Vec<tokio::sync::OwnedRwLockWriteGuard<()>>,
+}
diff --git a/crates/paimon/src/spec/core_options.rs
b/crates/paimon/src/spec/core_options.rs
index 0e447a15..3f806fd9 100644
--- a/crates/paimon/src/spec/core_options.rs
+++ b/crates/paimon/src/spec/core_options.rs
@@ -1902,6 +1902,10 @@ mod tests {
"unit '{unit}' should parse case-insensitively with a space"
);
}
+
+ for unit in ["kib", "mib", "gib", "tib"] {
+ assert_eq!(parse_memory_size(&format!("3{unit}")), None);
+ }
}
#[test]
diff --git a/docs/src/getting-started.md b/docs/src/getting-started.md
index 7a998910..5a872500 100644
--- a/docs/src/getting-started.md
+++ b/docs/src/getting-started.md
@@ -181,24 +181,24 @@ let catalog = CatalogFactory::create(options).await?;
| Option | Default | Description |
|--------|---------|-------------|
| `local-cache.enabled` | `false` | Enable catalog-scoped local block caching.
|
-| `local-cache.dir` | none | Base cache directory; required when caching is
enabled. Paimon stores entries in a private versioned child directory. |
-| `local-cache.max-size` | unlimited | Maximum encoded disk usage. Values
accept byte units such as `512 MiB` or `20 GiB`. |
+| `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`. |
-The cache is disk-only and is reused after process restarts. Cache keys include
-a catalog-configuration fingerprint and the canonical storage object path, so
-catalogs can safely use the same base directory without reading one another's
-entries. Runtime cache read, write, validation, and eviction failures are
-fail-open: the original storage remains the source of truth. Paimon mutable
-markers and temporary files always bypass the cache; other eligible files rely
-on Paimon's immutable-file convention. Cache managers using the same canonical
-directory in one process share LRU and size accounting; if their configured
-limits differ, the smallest `local-cache.max-size` is used. Restart recovery
-runs on a blocking worker, reads only block headers and file metadata, and
-validates payload CRC lazily on the first hit. Use a separate `local-cache.dir`
-for each worker or process because processes do not share exact LRU or size
-accounting.
+Each catalog owns its in-memory cache for the catalog's lifetime. Disk caches
+are reused after process restarts. Cache keys include a catalog-configuration
+fingerprint and the canonical storage object path, so catalogs can safely use
+the same base directory without reading one another's entries. Runtime cache
+read, write, validation, and eviction failures are fail-open: the original
+storage remains the source of truth. Paimon mutable markers and temporary files
+always bypass the cache; other eligible files rely on Paimon's immutable-file
+convention. Cache managers using the same canonical directory in one process
+share LRU, size accounting, and invalidation state; if their configured limits
+differ, the smallest `local-cache.max-size` is used. Restart recovery runs on a
+blocking worker, reads only block headers and file metadata, and validates
+payload CRC lazily on the first hit. Use a separate `local-cache.dir` for each
+worker or process because processes do not share exact LRU or size accounting.
### Manage Databases