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 c1e5810d feat: share parsed Parquet metadata across read contexts
(#1223)
c1e5810d is described below
commit c1e5810d2a15b429137db43eb4e5141fef226284
Author: XiaoHongbo <[email protected]>
AuthorDate: Tue Oct 27 16:10:54 2026 +0800
feat: share parsed Parquet metadata across read contexts (#1223)
---
crates/paimon/src/arrow/format/parquet.rs | 279 +++++++++++++++++++++++++++-
crates/paimon/src/common/options.rs | 3 +
crates/paimon/src/io/cache/reader.rs | 4 +
crates/paimon/src/io/file_io.rs | 251 ++++++++++++++++++++++++-
crates/paimon/src/table/data_file_reader.rs | 7 +
docs/src/getting-started.md | 32 ++--
6 files changed, 553 insertions(+), 23 deletions(-)
diff --git a/crates/paimon/src/arrow/format/parquet.rs
b/crates/paimon/src/arrow/format/parquet.rs
index aa2df217..69fdeafd 100644
--- a/crates/paimon/src/arrow/format/parquet.rs
+++ b/crates/paimon/src/arrow/format/parquet.rs
@@ -2902,6 +2902,7 @@ impl ParquetMetadataCache {
&self,
reader: &mut ArrowFileReader,
options: Option<&ArrowReaderOptions>,
+ shared: bool,
) -> parquet::errors::Result<Arc<ParquetMetaData>> {
let column_index = options.map_or(PageIndexPolicy::Skip, |o|
o.column_index_policy());
let offset_index = options.map_or(PageIndexPolicy::Skip, |o|
o.offset_index_policy());
@@ -2917,18 +2918,34 @@ impl ParquetMetadataCache {
offset_index: page_index_policy_tag(offset_index),
});
let key_heap_bytes = key.as_ref().map_or(0, |key| key.file.capacity());
- self.inner
+ let mut loaded = false;
+ let metadata = self
+ .inner
.get_or_try_insert_with(
key,
key_heap_bytes,
- || reader.load_metadata(options),
+ || {
+ loaded = true;
+ reader.load_metadata(options)
+ },
|metadata| {
metadata
.memory_size()
.max(PARQUET_METADATA_CACHE_MIN_ENTRY_BYTES)
},
)
- .await
+ .await?;
+ if shared
+ && !loaded
+ && !reader
+ .r
+ .check_shared_metadata_access()
+ .await
+ .map_err(|error|
parquet::errors::ParquetError::External(Box::new(error)))?
+ {
+ return reader.load_metadata(options).await;
+ }
+ Ok(metadata)
}
}
@@ -3165,9 +3182,10 @@ impl AsyncFileReader for ArrowFileReader {
if context.max_bytes() == 0 {
return self.load_metadata(options.as_ref()).await;
}
+ let shared = context.parquet_metadata_is_shared();
context
.get_or_init(ParquetMetadataCache::new)
- .load(self, options.as_ref())
+ .load(self, options.as_ref(), shared)
.await
})
}
@@ -5724,6 +5742,11 @@ mod tests {
#[async_trait::async_trait]
impl crate::io::FileRead for TrackingFileRead {
+ async fn check_shared_metadata_access(&self) -> crate::Result<bool> {
+ self.read(0..1).await?;
+ Ok(true)
+ }
+
async fn read(&self, range: std::ops::Range<u64>) ->
crate::Result<Bytes> {
self.ranges.lock().unwrap().push(range.clone());
// Count each source allocation until its last slice is dropped,
not slice lengths.
@@ -5769,6 +5792,114 @@ mod tests {
assert_eq!(tracker.read_count(), 1);
}
+ #[tokio::test]
+ async fn parquet_metadata_cache_shares_independently_created_contexts() {
+ if !crate::io::run_isolated_metadata_cache_test(
+
"arrow::format::parquet::tests::parquet_metadata_cache_shares_independently_created_contexts",
+ ) {
+ return;
+ }
+ let data = Bytes::from(
+ write_multi_row_group_parquet(8, 64, EnabledStatistics::Chunk,
false).await,
+ );
+ let props = HashMap::from([(
+
crate::common::CatalogOptions::FILE_FORMAT_METADATA_CACHE_SHARED.to_string(),
+ "true".to_string(),
+ )]);
+ let mut trackers = Vec::new();
+ let mut metadata = Vec::new();
+ for _ in 0..40 {
+ let context =
crate::io::FileFormatMetadataCacheContext::from_props(&props).unwrap();
+ let tracker = TrackingFileRead::new(data.clone())
+ .with_cache_key("process-sharing-test\0data.parquet")
+ .with_metadata_cache(context);
+ let mut reader =
+ super::ArrowFileReader::new(data.len() as u64,
Arc::new(tracker.clone()));
+ metadata.push(reader.get_metadata(None).await.unwrap());
+ trackers.push(tracker);
+ }
+ assert_eq!(
+ trackers
+ .iter()
+ .map(TrackingFileRead::read_count)
+ .sum::<usize>(),
+ 40
+ );
+ assert!(trackers
+ .iter()
+ .skip(1)
+ .all(|reader| reader.bytes_read() == 1));
+ assert!(metadata
+ .iter()
+ .all(|value| Arc::ptr_eq(value, &metadata[0])));
+ }
+
+ #[tokio::test]
+ async fn shared_metadata_rejects_an_unauthorized_reader() {
+ if !crate::io::run_isolated_metadata_cache_test(
+
"arrow::format::parquet::tests::shared_metadata_rejects_an_unauthorized_reader",
+ ) {
+ return;
+ }
+ struct DeniedReader(TrackingFileRead, bool, Arc<AtomicUsize>);
+ #[async_trait::async_trait]
+ impl crate::io::FileRead for DeniedReader {
+ async fn read(&self, _: std::ops::Range<u64>) ->
crate::Result<Bytes> {
+ Err(crate::Error::from(opendal::Error::new(
+ opendal::ErrorKind::PermissionDenied,
+ "denied",
+ )))
+ }
+ async fn check_shared_metadata_access(&self) ->
crate::Result<bool> {
+ self.2.fetch_add(1, AtomicOrdering::SeqCst);
+ if !self.1 {
+ return Ok(false);
+ }
+ self.read(0..1).await?;
+ Ok(true)
+ }
+ fn cache_key(&self) -> Option<&str> {
+ self.0.cache_key()
+ }
+ fn file_format_metadata_cache(&self) -> Option<&(dyn std::any::Any
+ Send + Sync)> {
+ self.0.file_format_metadata_cache()
+ }
+ }
+ let data = Bytes::from(
+ write_multi_row_group_parquet(8, 64, EnabledStatistics::Chunk,
false).await,
+ );
+ let props = HashMap::from([(
+
crate::common::CatalogOptions::FILE_FORMAT_METADATA_CACHE_SHARED.to_string(),
+ "true".to_string(),
+ )]);
+ let first = TrackingFileRead::new(data.clone())
+ .with_cache_key("authorization-test/data.parquet")
+ .with_metadata_cache(
+
crate::io::FileFormatMetadataCacheContext::from_props(&props).unwrap(),
+ );
+ let mut allowed = super::ArrowFileReader::new(data.len() as u64,
Arc::new(first));
+ allowed.get_metadata(None).await.unwrap();
+ let second = TrackingFileRead::new(data.clone())
+ .with_cache_key("authorization-test/data.parquet")
+ .with_metadata_cache(
+
crate::io::FileFormatMetadataCacheContext::from_props(&props).unwrap(),
+ );
+ for supports_check in [true, false] {
+ let checks = Arc::new(AtomicUsize::new(0));
+ let mut denied = super::ArrowFileReader::new(
+ data.len() as u64,
+ Arc::new(DeniedReader(second.clone(), supports_check,
checks.clone())),
+ );
+ assert!(denied
+ .get_metadata(None)
+ .await
+ .unwrap_err()
+ .to_string()
+ .contains("denied"));
+ assert_eq!(checks.load(AtomicOrdering::SeqCst), 1);
+ }
+ }
+
#[cfg(feature = "storage-fs")]
#[tokio::test]
async fn parquet_metadata_cache_reuses_metadata_across_file_io_instances()
{
@@ -5806,6 +5937,146 @@ mod tests {
assert!(Arc::ptr_eq(&first_metadata, &second_metadata));
}
+ #[cfg(feature = "storage-memory")]
+ #[tokio::test]
+ async fn shared_metadata_separates_recycled_memory_storage_identities() {
+ if !crate::io::run_isolated_metadata_cache_test(
+
"arrow::format::parquet::tests::shared_metadata_separates_recycled_memory_storage_identities",
+ ) {
+ return;
+ }
+ use crate::io::FileRead;
+
+ struct RecycledMemoryReader {
+ inner: Arc<dyn FileRead>,
+ key: String,
+ }
+ impl RecycledMemoryReader {
+ fn new(inner: impl FileRead) -> Self {
+ let mut parts =
inner.cache_key().unwrap().split('\0').collect::<Vec<_>>();
+ assert_eq!(parts.len(), 5);
+ assert_eq!(parts[1], "memory");
+ // Simulate OpenDAL reusing an address-based service name. Keep
+ // the real FileIO namespace, root and path; do not rely on
malloc.
+ parts[2] = "recycled-memory-address";
+ let key = parts.join("\0");
+ Self {
+ inner: Arc::new(inner),
+ key,
+ }
+ }
+ }
+ #[async_trait::async_trait]
+ impl FileRead for RecycledMemoryReader {
+ async fn read(&self, range: std::ops::Range<u64>) ->
crate::Result<Bytes> {
+ self.inner.read(range).await
+ }
+ async fn check_shared_metadata_access(&self) ->
crate::Result<bool> {
+ self.inner.check_shared_metadata_access().await
+ }
+ fn cache_key(&self) -> Option<&str> {
+ Some(&self.key)
+ }
+ fn file_format_metadata_cache(&self) -> Option<&(dyn std::any::Any
+ Send + Sync)> {
+ self.inner.file_format_metadata_cache()
+ }
+ }
+ fn parquet_data(name: &str, value: i32) -> Bytes {
+ let schema = Arc::new(ArrowSchema::new(vec![ArrowField::new(
+ name,
+ ArrowDataType::Int32,
+ false,
+ )]));
+ let batch = RecordBatch::try_new(
+ schema.clone(),
+ vec![Arc::new(Int32Array::from(vec![value; 4]))],
+ )
+ .unwrap();
+ let mut data = Vec::new();
+ let mut writer = parquet::arrow::ArrowWriter::try_new(&mut data,
schema, None).unwrap();
+ writer.write(&batch).unwrap();
+ writer.close().unwrap();
+ Bytes::from(data)
+ }
+
+ let first_data = parquet_data("first", 1);
+ let second_data = parquet_data("other", 9);
+ assert_eq!(first_data.len(), second_data.len());
+ let props = HashMap::from([(
+
crate::common::CatalogOptions::FILE_FORMAT_METADATA_CACHE_SHARED.to_string(),
+ "true".to_string(),
+ )]);
+ let _keep_pool_alive =
+
crate::io::FileFormatMetadataCacheContext::from_props(&props).unwrap();
+ let path = "memory:/recycled-identity.parquet";
+ let first = FileIOBuilder::new("memory")
+ .with_props(props.clone())
+ .build()
+ .unwrap();
+ first
+ .new_output(path)
+ .unwrap()
+ .write(first_data.clone())
+ .await
+ .unwrap();
+ let mut reader = super::ArrowFileReader::new(
+ first_data.len() as u64,
+ Arc::new(RecycledMemoryReader::new(
+ first.new_input(path).unwrap().reader().await.unwrap(),
+ )),
+ );
+ let first_metadata = reader.get_metadata(None).await.unwrap();
+ assert_eq!(
+ first_metadata
+ .file_metadata()
+ .schema_descr()
+ .column(0)
+ .name(),
+ "first"
+ );
+ let clone = first.clone();
+ let mut cloned_reader = super::ArrowFileReader::new(
+ first_data.len() as u64,
+ Arc::new(RecycledMemoryReader::new(
+ clone.new_input(path).unwrap().reader().await.unwrap(),
+ )),
+ );
+ assert!(Arc::ptr_eq(
+ &first_metadata,
+ &cloned_reader.get_metadata(None).await.unwrap()
+ ));
+ drop(cloned_reader);
+ drop(clone);
+ drop(reader);
+ drop(first);
+
+ let second = FileIOBuilder::new("memory")
+ .with_props(props)
+ .build()
+ .unwrap();
+ second
+ .new_output(path)
+ .unwrap()
+ .write(second_data.clone())
+ .await
+ .unwrap();
+ let mut reader = super::ArrowFileReader::new(
+ second_data.len() as u64,
+ Arc::new(RecycledMemoryReader::new(
+ second.new_input(path).unwrap().reader().await.unwrap(),
+ )),
+ );
+ let metadata = reader.get_metadata(None).await.unwrap();
+ assert_eq!(
+ metadata.file_metadata().schema_descr().column(0).name(),
+ "other"
+ );
+ match metadata.row_group(0).column(0).statistics().unwrap() {
+ ParquetStatistics::Int32(stats) => assert_eq!(stats.min_opt(),
Some(&9)),
+ _ => panic!("expected Int32 statistics"),
+ }
+ }
+
#[tokio::test]
async fn parquet_metadata_cache_can_be_bypassed() {
let data = Bytes::from(
diff --git a/crates/paimon/src/common/options.rs
b/crates/paimon/src/common/options.rs
index eeefb7d0..28eab750 100644
--- a/crates/paimon/src/common/options.rs
+++ b/crates/paimon/src/common/options.rs
@@ -101,6 +101,9 @@ impl CatalogOptions {
pub const FILE_FORMAT_METADATA_CACHE_MAX_SIZE: &'static str =
"file-format.metadata-cache.max-size";
+ /// Share parsed Parquet metadata across contexts in the same process
(default: false).
+ pub const FILE_FORMAT_METADATA_CACHE_SHARED: &'static str =
"file-format.metadata-cache.shared";
+
/// Maximum catalog-local cache size for decoded BLOB indexes.
pub const BLOB_INDEX_CACHE_MAX_SIZE: &'static str =
"cache.blob-index.max-size";
}
diff --git a/crates/paimon/src/io/cache/reader.rs
b/crates/paimon/src/io/cache/reader.rs
index d60099f7..a4a083d4 100644
--- a/crates/paimon/src/io/cache/reader.rs
+++ b/crates/paimon/src/io/cache/reader.rs
@@ -333,6 +333,10 @@ impl CachedFileReader {
#[async_trait::async_trait]
impl FileRead for CachedFileReader {
+ async fn check_shared_metadata_access(&self) -> crate::Result<bool> {
+ self.delegate.check_shared_metadata_access().await
+ }
+
async fn read(&self, range: Range<u64>) -> crate::Result<Bytes> {
if range.start > range.end || range.end > self.file_size {
return self.delegate.read(range).await;
diff --git a/crates/paimon/src/io/file_io.rs b/crates/paimon/src/io/file_io.rs
index 3e889450..acf9a2f4 100644
--- a/crates/paimon/src/io/file_io.rs
+++ b/crates/paimon/src/io/file_io.rs
@@ -120,11 +120,53 @@ const ROW_METADATA_CACHE_MAX_BYTES: usize = 32 * 1024 *
1024;
const ROW_METADATA_CACHE_BUDGET_DIVISOR: usize = 4;
const ROW_SIDECAR_SIZE_CACHE_MAX_ENTRIES: usize = 4096;
+type ParquetMetadataSlot = OnceLock<Arc<dyn Any + Send + Sync>>;
+type ParquetMetadataRegistry = HashMap<(u32, usize),
std::sync::Weak<ParquetMetadataSlot>>;
+static PARQUET_METADATA_SLOTS: Mutex<Option<ParquetMetadataRegistry>> =
Mutex::new(None);
+
+fn shared_parquet_metadata_slot(max_bytes: usize) -> Arc<ParquetMetadataSlot> {
+ // Sharing is best-effort; never wait on a registry lock inherited by fork.
+ let Ok(mut registry) = PARQUET_METADATA_SLOTS.try_lock() else {
+ return Arc::new(OnceLock::new());
+ };
+ let slots = registry.get_or_insert_with(HashMap::new);
+ slots.retain(|_, slot| slot.strong_count() > 0);
+ let key = (std::process::id(), max_bytes);
+ if let Some(slot) = slots.get(&key).and_then(std::sync::Weak::upgrade) {
+ return slot;
+ }
+ let slot = Arc::new(OnceLock::new());
+ slots.insert(key, Arc::downgrade(&slot));
+ slot
+}
+
+#[cfg(test)]
+pub(crate) fn run_isolated_metadata_cache_test(test_name: &str) -> bool {
+ const CHILD: &str = "PAIMON_TEST_ISOLATED_METADATA_CACHE";
+ if std::env::var(CHILD).as_deref() == Ok(test_name) {
+ return true;
+ }
+ // Registry contention legitimately disables sharing. Tests requiring a
+ // shared slot must not race unrelated tests constructing cache contexts.
+ let status = std::process::Command::new(std::env::current_exe().unwrap())
+ .args(["--exact", test_name, "--test-threads=1"])
+ .env(CHILD, test_name)
+ .status()
+ .unwrap();
+ assert!(
+ status.success(),
+ "isolated test {test_name} failed: {status}"
+ );
+ false
+}
+
pub(crate) struct FileFormatMetadataCacheContext {
max_bytes: usize,
- cache: OnceLock<Arc<dyn Any + Send + Sync>>,
+ shared: bool,
+ cache: Arc<ParquetMetadataSlot>,
row_metadata_cache: OnceLock<Arc<dyn Any + Send + Sync>>,
process_id: u32,
+ memory_identity: uuid::Uuid,
row_sidecar_sizes: OnceLock<Mutex<LruCache<String, u64>>>,
}
@@ -153,11 +195,30 @@ impl FileFormatMetadataCacheContext {
})
.transpose()?
.unwrap_or(DEFAULT_FILE_FORMAT_METADATA_CACHE_MAX_BYTES);
+ let shared = props
+ .get(CatalogOptions::FILE_FORMAT_METADATA_CACHE_SHARED)
+ .map(|value| {
+ value.parse::<bool>().map_err(|_| Error::ConfigInvalid {
+ message: format!(
+ "Invalid value for {}: {value}",
+ CatalogOptions::FILE_FORMAT_METADATA_CACHE_SHARED
+ ),
+ })
+ })
+ .transpose()?
+ .unwrap_or(false);
+ let cache = if shared && max_bytes > 0 {
+ shared_parquet_metadata_slot(max_bytes)
+ } else {
+ Arc::new(OnceLock::new())
+ };
Ok(Arc::new(Self {
max_bytes,
- cache: OnceLock::new(),
+ shared,
+ cache,
row_metadata_cache: OnceLock::new(),
process_id: std::process::id(),
+ memory_identity: uuid::Uuid::new_v4(),
row_sidecar_sizes: OnceLock::new(),
}))
}
@@ -231,6 +292,9 @@ impl FileFormatMetadataCacheContext {
where
T: Send + Sync + 'static,
{
+ if self.process_id != std::process::id() {
+ return Arc::new(init(self.parquet_metadata_max_bytes()));
+ }
let cache = Arc::clone(self.cache.get_or_init(|| {
Arc::new(init(self.parquet_metadata_max_bytes())) as Arc<dyn Any +
Send + Sync>
}));
@@ -240,6 +304,10 @@ impl FileFormatMetadataCacheContext {
}
}
+ pub(crate) fn parquet_metadata_is_shared(&self) -> bool {
+ self.shared && self.process_id == std::process::id()
+ }
+
/// ROW footer and block index metadata has its own typed cache. Keep it
/// separate from the Parquet cache, which also uses `get_or_init`.
pub(crate) fn row_metadata_get_or_init<T>(
@@ -1176,6 +1244,14 @@ impl FileIOBuilder {
pub trait FileRead: Send + Sync + Unpin + 'static {
async fn read(&self, range: Range<u64>) -> crate::Result<Bytes>;
+ /// Check current access before reusing metadata from another context.
+ /// Implementations must bypass data caches and propagate storage errors.
+ /// Returning false disables cross-context metadata reuse for this reader.
+ #[doc(hidden)]
+ async fn check_shared_metadata_access(&self) -> crate::Result<bool> {
+ Ok(false)
+ }
+
#[doc(hidden)]
#[deprecated(note = "BLOB index caching no longer uses numeric FileIO
namespaces")]
fn cache_namespace(&self) -> Option<usize> {
@@ -1200,6 +1276,11 @@ pub trait FileRead: Send + Sync + Unpin + 'static {
#[async_trait::async_trait]
impl FileRead for opendal::Reader {
+ async fn check_shared_metadata_access(&self) -> crate::Result<bool> {
+ self.read(0..1).await?;
+ Ok(true)
+ }
+
async fn read(&self, range: Range<u64>) -> crate::Result<Bytes> {
Ok(opendal::Reader::read(self, range).await?.to_bytes())
}
@@ -1222,6 +1303,13 @@ enum InputFileReader {
#[async_trait::async_trait]
impl FileRead for InputFileReader {
+ async fn check_shared_metadata_access(&self) -> crate::Result<bool> {
+ match self {
+ Self::Direct { reader, .. } =>
reader.check_shared_metadata_access().await,
+ Self::Cached { reader, .. } =>
reader.check_shared_metadata_access().await,
+ }
+ }
+
async fn read(&self, range: Range<u64>) -> crate::Result<Bytes> {
match self {
Self::Direct { reader, .. } => FileRead::read(reader, range).await,
@@ -1589,11 +1677,21 @@ impl InputFile {
async fn reader_with_size_hint(&self, size: Option<u64>) ->
crate::Result<impl FileRead> {
let (op, relative_path, cache_path) =
self.source.resolve(&self.path).await?;
+ let cache_key = cache_path.as_ref().map(|path| {
+ if op.info().scheme() == "memory" {
+ // OpenDAL's address-based Memory name can be recycled. Keep
+ // parsed metadata context-local without changing block-cache
+ // keys or invalidation for operators shared by several
FileIOs.
+ format!("{}:{path}",
self.file_format_metadata_cache.memory_identity)
+ } else {
+ path.clone()
+ }
+ });
let reader = op.reader(&relative_path).await?;
let (Some(cache), Some(cache_path)) = (&self.cache,
cache_path.as_ref()) else {
return Ok(InputFileReader::Direct {
reader,
- cache_key: cache_path,
+ cache_key,
file_format_metadata_cache:
Arc::clone(&self.file_format_metadata_cache),
blob_index_cache: Arc::clone(&self.blob_index_cache),
});
@@ -1616,7 +1714,7 @@ impl InputFile {
cache.clone(),
read_token,
),
- cache_key: cache_path.clone(),
+ cache_key: cache_key.unwrap(),
file_format_metadata_cache:
Arc::clone(&self.file_format_metadata_cache),
blob_index_cache: Arc::clone(&self.blob_index_cache),
})
@@ -2693,6 +2791,38 @@ mod input_output_test {
);
}
+ #[cfg(feature = "storage-memory")]
+ #[tokio::test]
+ async fn test_memory_metadata_keys_are_context_scoped() {
+ let first = setup_memory_file_io();
+ let path = "memory:/metadata.parquet";
+ let first_reader =
first.new_input(path).unwrap().reader().await.unwrap();
+ let clone = first.clone();
+ let cloned_reader =
clone.new_input(path).unwrap().reader().await.unwrap();
+ assert_eq!(first_reader.cache_key(), cloned_reader.cache_key());
+ let second = setup_memory_file_io();
+ let second_reader =
second.new_input(path).unwrap().reader().await.unwrap();
+ assert_ne!(first_reader.cache_key(), second_reader.cache_key());
+
+ let operator =
Operator::from_config(opendal::services::MemoryConfig::default()).unwrap();
+ let first = FileIOBuilder::new("file")
+ .with_fs_operator(operator.clone())
+ .build()
+ .unwrap();
+ let second = FileIOBuilder::new("file")
+ .with_fs_operator(operator)
+ .build()
+ .unwrap();
+ let path = "file:/metadata.parquet";
+ let first_reader =
first.new_input(path).unwrap().reader().await.unwrap();
+ let second_reader =
second.new_input(path).unwrap().reader().await.unwrap();
+ assert_ne!(first_reader.cache_key(), second_reader.cache_key());
+ assert_eq!(
+ first.create_with_cache_path(path).await.unwrap().2,
+ second.create_with_cache_path(path).await.unwrap().2,
+ );
+ }
+
#[cfg(all(feature = "storage-fs", feature = "storage-memory"))]
#[test]
fn test_cache_object_path_distinguishes_storage_schemes() {
@@ -3172,6 +3302,119 @@ mod input_output_test {
.contains(CatalogOptions::FILE_FORMAT_METADATA_CACHE_MAX_SIZE));
}
+ #[test]
+ fn test_shared_metadata_cache_budget_lifetime_and_row_isolation() {
+ if !run_isolated_metadata_cache_test(
+
"io::file_io::input_output_test::test_shared_metadata_cache_budget_lifetime_and_row_isolation",
+ ) {
+ return;
+ }
+ let mut props = HashMap::from([
+ (
+ CatalogOptions::FILE_FORMAT_METADATA_CACHE_SHARED.to_string(),
+ "true".to_string(),
+ ),
+ (
+
CatalogOptions::FILE_FORMAT_METADATA_CACHE_MAX_SIZE.to_string(),
+ "123456".to_string(),
+ ),
+ ]);
+ let first =
FileFormatMetadataCacheContext::from_props(&props).unwrap();
+ let second =
FileFormatMetadataCacheContext::from_props(&props).unwrap();
+ assert!(Arc::ptr_eq(&first.cache, &second.cache));
+ let row_first = first.row_metadata_get_or_init(|_| 1usize).unwrap();
+ let row_second = second.row_metadata_get_or_init(|_| 2usize).unwrap();
+ assert!(!Arc::ptr_eq(&row_first, &row_second));
+ props.insert(
+ CatalogOptions::FILE_FORMAT_METADATA_CACHE_MAX_SIZE.to_string(),
+ "0".to_string(),
+ );
+ let zero = FileFormatMetadataCacheContext::from_props(&props).unwrap();
+ assert!(!Arc::ptr_eq(&first.cache, &zero.cache));
+ let weak = Arc::downgrade(&first.cache);
+ drop(first);
+ drop(second);
+ assert!(weak.upgrade().is_none());
+ }
+
+ #[test]
+ fn test_shared_metadata_cache_falls_back_under_registry_contention() {
+ if !run_isolated_metadata_cache_test(
+
"io::file_io::input_output_test::test_shared_metadata_cache_falls_back_under_registry_contention",
+ ) {
+ return;
+ }
+ let props = HashMap::from([(
+ CatalogOptions::FILE_FORMAT_METADATA_CACHE_SHARED.to_string(),
+ "true".to_string(),
+ )]);
+ let shared =
FileFormatMetadataCacheContext::from_props(&props).unwrap();
+ let guard = PARQUET_METADATA_SLOTS.lock().unwrap();
+ let (tx, rx) = std::sync::mpsc::channel();
+ let concurrent_props = props.clone();
+ let constructor = std::thread::spawn(move || {
+ tx.send(FileFormatMetadataCacheContext::from_props(
+ &concurrent_props,
+ ))
+ .unwrap();
+ });
+ let fallback = rx.recv_timeout(std::time::Duration::from_secs(5));
+ // Release the gate before joining, even if construction incorrectly
waited.
+ drop(guard);
+ constructor.join().unwrap();
+ let fallback = fallback
+ .expect("cache construction must not wait for the registry lock")
+ .unwrap();
+ assert!(!Arc::ptr_eq(&shared.cache, &fallback.cache));
+ let available =
FileFormatMetadataCacheContext::from_props(&props).unwrap();
+ assert!(Arc::ptr_eq(&shared.cache, &available.cache));
+ }
+
+ #[test]
+ fn test_shared_metadata_cache_rejects_invalid_flag() {
+ let props = HashMap::from([(
+ CatalogOptions::FILE_FORMAT_METADATA_CACHE_SHARED.to_string(),
+ "invalid".to_string(),
+ )]);
+ assert!(FileFormatMetadataCacheContext::from_props(&props).is_err());
+ }
+
+ #[cfg(unix)]
+ #[test]
+ fn test_shared_metadata_registry_fork_with_locked_registry() {
+ if !run_isolated_metadata_cache_test(
+
"io::file_io::input_output_test::test_shared_metadata_registry_fork_with_locked_registry",
+ ) {
+ return;
+ }
+ // The child must not wait for a lock whose owning thread stayed in
the parent.
+ let guard = PARQUET_METADATA_SLOTS.lock().unwrap();
+ unsafe {
+ let pid = libc::fork();
+ assert!(pid >= 0);
+ if pid == 0 {
+ libc::alarm(3);
+ let slot = shared_parquet_metadata_slot(123456);
+ libc::_exit(if slot.get().is_none() { 0 } else { 1 });
+ }
+ let mut status = 0;
+ assert_eq!(libc::waitpid(pid, &mut status, 0), pid);
+ assert!(libc::WIFEXITED(status));
+ assert_eq!(libc::WEXITSTATUS(status), 0);
+ }
+ drop(guard);
+ }
+
+ #[test]
+ fn test_metadata_cache_bypasses_inherited_context() {
+ let mut context =
FileFormatMetadataCacheContext::from_props(&HashMap::new()).unwrap();
+ let before = context.get_or_init(|_| 1usize);
+ Arc::get_mut(&mut context).unwrap().process_id = 0;
+ let after = context.get_or_init(|_| 2usize);
+ assert_eq!(*after, 2);
+ assert!(!Arc::ptr_eq(&before, &after));
+ }
+
#[test]
fn test_blob_index_cache_size_rejects_invalid_value() {
let error = FileIOBuilder::new("memory")
diff --git a/crates/paimon/src/table/data_file_reader.rs
b/crates/paimon/src/table/data_file_reader.rs
index 7c47cfcd..47209872 100644
--- a/crates/paimon/src/table/data_file_reader.rs
+++ b/crates/paimon/src/table/data_file_reader.rs
@@ -106,6 +106,13 @@ struct TimedFileRead {
#[async_trait::async_trait]
impl FileRead for TimedFileRead {
+ async fn check_shared_metadata_access(&self) -> crate::Result<bool> {
+ let start = Instant::now();
+ let result = self.inner.check_shared_metadata_access().await;
+ self.timing.add_file_read(start.elapsed());
+ result
+ }
+
async fn read(&self, range: Range<u64>) -> crate::Result<bytes::Bytes> {
let start = Instant::now();
let result = self.inner.read(range).await;
diff --git a/docs/src/getting-started.md b/docs/src/getting-started.md
index b6c8ff9b..c75554f2 100644
--- a/docs/src/getting-started.md
+++ b/docs/src/getting-started.md
@@ -426,21 +426,23 @@ 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. Without shared mode, use a separate
-`local-cache.dir` per worker or process.
-
-For sharing, set `local-cache.enabled=true` and `local-cache.shared=true` in
-same-UID workers with the same node-local directory and capacity. NFS,
-cross-node sharing, and catalogs inherited across `fork` are unsupported.
-Invalidation clears the shared cache; if it fails after a storage mutation,
-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.
+`local-cache.dir` per process.
+
+To share disk blocks, set `local-cache.enabled=true`,
`local-cache.shared=true`,
+`local-cache.dir`, and `local-cache.max-size` consistently in same-UID
processes
+on one node. NFS, cross-node sharing, and catalog reuse after `fork` are
+unsupported. After failed invalidation or rename, verify storage state and use
+a fresh cache directory before resuming sharing. Allow disk headroom for up to
+eight staged 1-MiB writes per cache instance beyond `local-cache.max-size`.
+
+### Parsed Parquet Metadata Cache
+
+Set `file-format.metadata-cache.shared=true` to reuse parsed Parquet metadata
+across read contexts in one process (default `false`). Only equal budgets share
+a pool; this is not a process-wide memory limit. Shared hits perform an
uncached
+one-byte storage-access check; readers without that check reload metadata.
+ROW metadata and BLOB indexes remain context-local. This is independent of
+`local-cache.shared`, which shares disk blocks across processes.
### BLOB Index Cache