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 603ecdec [rust] Cache parsed Parquet metadata across readers (#929)
603ecdec is described below
commit 603ecdec20195a8071aa28404112f7fc94d3f2d4
Author: XiaoHongbo <[email protected]>
AuthorDate: Mon Sep 28 20:18:24 2026 +0800
[rust] Cache parsed Parquet metadata across readers (#929)
---
crates/paimon/src/arrow/format/metadata_cache.rs | 412 +++++++++++++++++++++
crates/paimon/src/arrow/format/mod.rs | 1 +
crates/paimon/src/arrow/format/parquet.rs | 377 +++++++++++++++++--
crates/paimon/src/catalog/rest/rest_catalog.rs | 8 +
.../paimon/src/catalog/rest/rest_token_file_io.rs | 18 +-
crates/paimon/src/common/options.rs | 8 +
crates/paimon/src/io/file_io.rs | 280 +++++++++++++-
crates/paimon/src/table/data_file_reader.rs | 8 +
crates/paimon/src/table/rest_env.rs | 72 +++-
crates/paimon/src/table/snapshot_manager.rs | 12 +-
.../paimon/src/table/table_commit/parity_tests.rs | 13 +-
.../src/table/table_commit/recovery_tests.rs | 13 +-
12 files changed, 1170 insertions(+), 52 deletions(-)
diff --git a/crates/paimon/src/arrow/format/metadata_cache.rs
b/crates/paimon/src/arrow/format/metadata_cache.rs
new file mode 100644
index 00000000..5bb140ad
--- /dev/null
+++ b/crates/paimon/src/arrow/format/metadata_cache.rs
@@ -0,0 +1,412 @@
+// 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 lru::LruCache;
+use std::future::Future;
+use std::hash::Hash;
+use std::sync::atomic::{AtomicUsize, Ordering};
+use std::sync::{Arc, Mutex};
+use tokio::sync::OnceCell;
+
+struct Entry<V> {
+ value: OnceCell<Arc<V>>,
+ base_weight: usize,
+ weight: AtomicUsize,
+ users: AtomicUsize,
+}
+
+struct State<K, V> {
+ entries: LruCache<K, Arc<Entry<V>>>,
+ weight: usize,
+}
+
+pub(super) struct FileMetadataCache<K, V> {
+ max_bytes: usize,
+ max_entries: usize,
+ state: Mutex<State<K, V>>,
+}
+
+struct EntryGuard<'a, K, V>
+where
+ K: Clone + Eq + Hash,
+{
+ cache: &'a FileMetadataCache<K, V>,
+ key: K,
+ entry: Arc<Entry<V>>,
+}
+
+impl<'a, K, V> EntryGuard<'a, K, V>
+where
+ K: Clone + Eq + Hash,
+{
+ fn new(cache: &'a FileMetadataCache<K, V>, key: K, entry: Arc<Entry<V>>)
-> Self {
+ Self { cache, key, entry }
+ }
+}
+
+impl<K, V> Drop for EntryGuard<'_, K, V>
+where
+ K: Clone + Eq + Hash,
+{
+ fn drop(&mut self) {
+ self.cache.release(&self.key, &self.entry);
+ }
+}
+
+impl<K, V> FileMetadataCache<K, V>
+where
+ K: Clone + Eq + Hash,
+{
+ pub(super) fn new(max_bytes: usize, max_entries: usize) -> Self {
+ Self {
+ max_bytes,
+ max_entries,
+ state: Mutex::new(State {
+ entries: LruCache::unbounded(),
+ weight: 0,
+ }),
+ }
+ }
+
+ pub(super) fn entry_weight(key_heap_bytes: usize, value_weight: usize) ->
usize {
+ std::mem::size_of::<K>()
+ .saturating_add(std::mem::size_of::<Entry<V>>())
+ .saturating_add(key_heap_bytes)
+ .saturating_add(value_weight)
+ }
+
+ pub(super) async fn get_or_try_insert_with<E, F, Fut, W>(
+ &self,
+ key: Option<K>,
+ key_heap_bytes: usize,
+ load: F,
+ value_weight: W,
+ ) -> Result<Arc<V>, E>
+ where
+ F: FnOnce() -> Fut,
+ Fut: Future<Output = Result<Arc<V>, E>>,
+ W: FnOnce(&V) -> usize,
+ {
+ let Some(key) = key else {
+ return load().await;
+ };
+ let base_weight = Self::entry_weight(key_heap_bytes, 0);
+ if self.max_bytes == 0 || base_weight > self.max_bytes {
+ return load().await;
+ }
+
+ let entry = {
+ let mut state = self.state.lock().unwrap();
+ let entry = if let Some(entry) = state.entries.get(&key) {
+ Arc::clone(entry)
+ } else {
+ let entry = Arc::new(Entry {
+ value: OnceCell::new(),
+ base_weight,
+ weight: AtomicUsize::new(base_weight),
+ users: AtomicUsize::new(0),
+ });
+ state.weight = state.weight.saturating_add(base_weight);
+ state.entries.put(key.clone(), Arc::clone(&entry));
+ entry
+ };
+ entry.users.fetch_add(1, Ordering::Relaxed);
+ entry
+ };
+ let _guard = EntryGuard::new(self, key.clone(), Arc::clone(&entry));
+
+ let value = entry.value.get_or_try_init(load).await.map(Arc::clone)?;
+ if entry.weight.load(Ordering::Relaxed) != entry.base_weight {
+ return Ok(value);
+ }
+ let loaded_weight = entry
+ .base_weight
+ .saturating_add(value_weight(value.as_ref()).max(1));
+ let mut state = self.state.lock().unwrap();
+ if state
+ .entries
+ .peek(&key)
+ .is_some_and(|cached| Arc::ptr_eq(cached, &entry))
+ && entry.weight.load(Ordering::Relaxed) == entry.base_weight
+ {
+ if loaded_weight > self.max_bytes {
+ state.entries.pop(&key);
+ state.weight = state.weight.saturating_sub(entry.base_weight);
+ return Ok(value);
+ }
+ entry.weight.store(loaded_weight, Ordering::Relaxed);
+ state.weight = state
+ .weight
+ .saturating_sub(entry.base_weight)
+ .saturating_add(loaded_weight);
+ state.entries.promote(&key);
+ self.evict(&mut state);
+ }
+ Ok(value)
+ }
+
+ fn release(&self, key: &K, entry: &Arc<Entry<V>>) {
+ let mut state = self.state.lock().unwrap();
+ let users = entry.users.fetch_sub(1, Ordering::Relaxed);
+ debug_assert!(users > 0);
+ if users == 1
+ && entry.value.get().is_none()
+ && state
+ .entries
+ .peek(key)
+ .is_some_and(|cached| Arc::ptr_eq(cached, entry))
+ {
+ state.entries.pop(key);
+ state.weight = state
+ .weight
+ .saturating_sub(entry.weight.load(Ordering::Relaxed));
+ }
+ }
+
+ fn evict(&self, state: &mut State<K, V>) {
+ while state.weight > self.max_bytes || state.entries.len() >
self.max_entries {
+ let Some(key) = state
+ .entries
+ .iter()
+ .rev()
+ .find(|(_, entry)| entry.weight.load(Ordering::Relaxed) !=
entry.base_weight)
+ .map(|(key, _)| key.clone())
+ else {
+ // In-flight loads remain addressable so callers still
coalesce.
+ break;
+ };
+ let Some(entry) = state.entries.pop(&key) else {
+ state.weight = 0;
+ break;
+ };
+ state.weight = state
+ .weight
+ .saturating_sub(entry.weight.load(Ordering::Relaxed));
+ }
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use std::convert::Infallible;
+ use std::future::pending;
+ use std::time::Duration;
+
+ #[tokio::test]
+ async fn evicts_least_recently_used_entry_by_count() {
+ let cache = FileMetadataCache::<String, usize>::new(usize::MAX, 2);
+ let loads = AtomicUsize::new(0);
+
+ for key in ["first", "second", "third", "first"] {
+ cache
+ .get_or_try_insert_with(
+ Some(key.to_string()),
+ key.len(),
+ || async {
+ loads.fetch_add(1, Ordering::Relaxed);
+ Ok::<_, Infallible>(Arc::new(1))
+ },
+ |_| 1,
+ )
+ .await
+ .unwrap();
+ }
+
+ assert_eq!(loads.load(Ordering::Relaxed), 4);
+ }
+
+ #[tokio::test]
+ async fn cache_hit_does_not_recalculate_weight() {
+ let cache = FileMetadataCache::<String, usize>::new(1024, 2);
+ let loads = AtomicUsize::new(0);
+ let weights = AtomicUsize::new(0);
+
+ for _ in 0..2 {
+ cache
+ .get_or_try_insert_with(
+ Some("shared".to_string()),
+ 6,
+ || async {
+ loads.fetch_add(1, Ordering::Relaxed);
+ Ok::<_, Infallible>(Arc::new(7))
+ },
+ |_| {
+ weights.fetch_add(1, Ordering::Relaxed);
+ 7
+ },
+ )
+ .await
+ .unwrap();
+ }
+
+ assert_eq!(loads.load(Ordering::Relaxed), 1);
+ assert_eq!(weights.load(Ordering::Relaxed), 1);
+ }
+
+ #[tokio::test]
+ async fn removes_cancelled_load() {
+ let cache = Arc::new(FileMetadataCache::<String, usize>::new(1024, 2));
+ let task_cache = Arc::clone(&cache);
+ let (started_tx, started_rx) = tokio::sync::oneshot::channel();
+
+ let task = tokio::spawn(async move {
+ task_cache
+ .get_or_try_insert_with(
+ Some("cancelled".to_string()),
+ 9,
+ || async move {
+ started_tx.send(()).unwrap();
+ pending::<Result<Arc<usize>, Infallible>>().await
+ },
+ |_| 1,
+ )
+ .await
+ });
+ started_rx.await.unwrap();
+ task.abort();
+ assert!(task.await.unwrap_err().is_cancelled());
+
+ let state = cache.state.lock().unwrap();
+ assert!(state.entries.is_empty());
+ assert_eq!(state.weight, 0);
+ }
+
+ #[tokio::test]
+ async fn waiter_caches_value_after_initializer_is_cancelled() {
+ let cache = Arc::new(FileMetadataCache::<String, usize>::new(1024, 2));
+ let loads = Arc::new(AtomicUsize::new(0));
+ let (started_tx, started_rx) = tokio::sync::oneshot::channel();
+
+ let first_cache = Arc::clone(&cache);
+ let first_loads = Arc::clone(&loads);
+ let first = tokio::spawn(async move {
+ first_cache
+ .get_or_try_insert_with(
+ Some("shared".to_string()),
+ 6,
+ || async move {
+ first_loads.fetch_add(1, Ordering::Relaxed);
+ started_tx.send(()).unwrap();
+ pending::<Result<Arc<usize>, Infallible>>().await
+ },
+ |_| 1,
+ )
+ .await
+ });
+ started_rx.await.unwrap();
+
+ let second_cache = Arc::clone(&cache);
+ let second_loads = Arc::clone(&loads);
+ let (waiting_tx, waiting_rx) = tokio::sync::oneshot::channel();
+ let second = tokio::spawn(async move {
+ waiting_tx.send(()).unwrap();
+ second_cache
+ .get_or_try_insert_with(
+ Some("shared".to_string()),
+ 6,
+ || async move {
+ second_loads.fetch_add(1, Ordering::Relaxed);
+ Ok::<_, Infallible>(Arc::new(7))
+ },
+ |_| 1,
+ )
+ .await
+ });
+ waiting_rx.await.unwrap();
+ tokio::time::timeout(Duration::from_secs(1), async {
+ loop {
+ let users = cache
+ .state
+ .lock()
+ .unwrap()
+ .entries
+ .iter()
+ .next()
+ .unwrap()
+ .1
+ .users
+ .load(Ordering::Relaxed);
+ if users == 2 {
+ break;
+ }
+ tokio::task::yield_now().await;
+ }
+ })
+ .await
+ .unwrap();
+
+ first.abort();
+ assert!(first.await.unwrap_err().is_cancelled());
+ assert_eq!(*second.await.unwrap().unwrap(), 7);
+
+ let third_loads = Arc::clone(&loads);
+ let value = cache
+ .get_or_try_insert_with(
+ Some("shared".to_string()),
+ 6,
+ || async move {
+ third_loads.fetch_add(1, Ordering::Relaxed);
+ Ok::<_, Infallible>(Arc::new(8))
+ },
+ |_| 1,
+ )
+ .await
+ .unwrap();
+
+ assert_eq!(*value, 7);
+ assert_eq!(loads.load(Ordering::Relaxed), 2);
+ }
+
+ #[tokio::test]
+ async fn oversized_load_does_not_evict_cached_entries() {
+ let cache = FileMetadataCache::<String, usize>::new(512, 2);
+ let small_loads = AtomicUsize::new(0);
+ let oversized_loads = AtomicUsize::new(0);
+
+ for _ in 0..2 {
+ cache
+ .get_or_try_insert_with(
+ Some("small".to_string()),
+ 5,
+ || async {
+ small_loads.fetch_add(1, Ordering::Relaxed);
+ Ok::<_, Infallible>(Arc::new(1))
+ },
+ |value| *value,
+ )
+ .await
+ .unwrap();
+
+ cache
+ .get_or_try_insert_with(
+ Some("oversized".to_string()),
+ 9,
+ || async {
+ oversized_loads.fetch_add(1, Ordering::Relaxed);
+ Ok::<_, Infallible>(Arc::new(1024))
+ },
+ |value| *value,
+ )
+ .await
+ .unwrap();
+ }
+
+ assert_eq!(small_loads.load(Ordering::Relaxed), 1);
+ assert_eq!(oversized_loads.load(Ordering::Relaxed), 2);
+ }
+}
diff --git a/crates/paimon/src/arrow/format/mod.rs
b/crates/paimon/src/arrow/format/mod.rs
index a9b46f92..0e0d00bd 100644
--- a/crates/paimon/src/arrow/format/mod.rs
+++ b/crates/paimon/src/arrow/format/mod.rs
@@ -18,6 +18,7 @@
mod avro;
mod avro_write;
pub(crate) mod blob;
+mod metadata_cache;
mod mosaic;
mod mosaic_write;
mod orc;
diff --git a/crates/paimon/src/arrow/format/parquet.rs
b/crates/paimon/src/arrow/format/parquet.rs
index 441fe049..5ce567bf 100644
--- a/crates/paimon/src/arrow/format/parquet.rs
+++ b/crates/paimon/src/arrow/format/parquet.rs
@@ -15,6 +15,7 @@
// specific language governing permissions and limitations
// under the License.
+use super::metadata_cache::FileMetadataCache;
use super::shredding::PhysicalFormatWriterFactory;
use super::{
timestamp_millis_schema, FilePredicates, FormatFileReader,
FormatFileWriter, FormatWriteResult,
@@ -60,6 +61,7 @@ use tokio::sync::{mpsc, oneshot};
pub(crate) struct ParquetFormatReader {
read_budget: Option<Arc<ReadBudget>>,
page_index_enabled: bool,
+ metadata_cache_enabled: bool,
}
impl Default for ParquetFormatReader {
@@ -67,6 +69,7 @@ impl Default for ParquetFormatReader {
Self {
read_budget: None,
page_index_enabled: true,
+ metadata_cache_enabled: true,
}
}
}
@@ -76,10 +79,12 @@ impl ParquetFormatReader {
options: &HashMap<String, String>,
read_budget: Option<Arc<ReadBudget>>,
) -> crate::Result<Self> {
+ let core_options = CoreOptions::new(options);
Ok(Self {
read_budget,
- page_index_enabled: crate::spec::CoreOptions::new(options)
- .parquet_filter_column_index_enabled()?,
+ page_index_enabled:
core_options.parquet_filter_column_index_enabled()?,
+ // Format-table files may be replaced in place.
+ metadata_cache_enabled: !core_options.is_format_table(),
})
}
@@ -607,7 +612,8 @@ impl FormatFileReader for ParquetFormatReader {
row_selection: Option<Vec<RowRange>>,
) -> crate::Result<ArrowRecordBatchStream> {
let shared_reader: Arc<dyn FileRead> = reader.into();
- let arrow_file_reader = ArrowFileReader::new(file_size,
Arc::clone(&shared_reader));
+ let arrow_file_reader = ArrowFileReader::new(file_size,
Arc::clone(&shared_reader))
+ .with_metadata_cache_enabled(self.metadata_cache_enabled);
let empty_predicates = Vec::new();
let (preds, file_fields): (&[Predicate], &[DataField]) = match
predicates {
@@ -2462,6 +2468,70 @@ fn build_row_ranges_selection(
// ArrowFileReader — async Parquet IO adapter
// ---------------------------------------------------------------------------
+const PARQUET_METADATA_CACHE_MAX_ENTRIES: usize = 4096;
+const PARQUET_METADATA_CACHE_MIN_ENTRY_BYTES: usize = 8 * 1024;
+
+#[derive(Clone, Debug, Eq, Hash, PartialEq)]
+struct ParquetMetadataCacheKey {
+ file: String,
+ size: u64,
+ column_index: u8,
+ offset_index: u8,
+}
+
+struct ParquetMetadataCache {
+ inner: FileMetadataCache<ParquetMetadataCacheKey, ParquetMetaData>,
+}
+
+impl ParquetMetadataCache {
+ fn new(max_bytes: usize) -> Self {
+ Self {
+ inner: FileMetadataCache::new(max_bytes,
PARQUET_METADATA_CACHE_MAX_ENTRIES),
+ }
+ }
+
+ async fn load(
+ &self,
+ reader: &mut ArrowFileReader,
+ options: Option<&ArrowReaderOptions>,
+ ) -> 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());
+ let key = reader
+ .r
+ .cache_key()
+ .filter(|key| !key.is_empty())
+ .map(|file| ParquetMetadataCacheKey {
+ file: file.to_string(),
+ // Paimon data files are immutable; size separates supported
replacements.
+ size: reader.file_size,
+ column_index: page_index_policy_tag(column_index),
+ offset_index: page_index_policy_tag(offset_index),
+ });
+ let key_heap_bytes = key.as_ref().map_or(0, |key| key.file.capacity());
+ self.inner
+ .get_or_try_insert_with(
+ key,
+ key_heap_bytes,
+ || reader.load_metadata(options),
+ |metadata| {
+ metadata
+ .memory_size()
+ .max(PARQUET_METADATA_CACHE_MIN_ENTRY_BYTES)
+ },
+ )
+ .await
+ }
+}
+
+fn page_index_policy_tag(policy: PageIndexPolicy) -> u8 {
+ match policy {
+ PageIndexPolicy::Skip => 0,
+ PageIndexPolicy::Optional => 1,
+ PageIndexPolicy::Required => 2,
+ }
+}
+
/// ArrowFileReader is a wrapper around a FileRead that impls parquets
AsyncFileReader.
///
/// # TODO
@@ -2475,6 +2545,7 @@ fn build_row_ranges_selection(
struct ArrowFileReader {
file_size: u64,
r: Arc<dyn FileRead>,
+ metadata_cache_enabled: bool,
}
/// coalesce threshold: 1 MiB.
@@ -2494,10 +2565,10 @@ pub(crate) async fn read_row_count(
reader: Box<dyn FileRead>,
file_size: u64,
) -> crate::Result<i64> {
- let reader = ArrowFileReader::new(file_size, Arc::from(reader));
- let metadata = ParquetMetaDataReader::new()
- .with_prefetch_hint(Some(METADATA_SIZE_HINT))
- .load_and_finish(reader, file_size)
+ let mut reader =
+ ArrowFileReader::new(file_size,
Arc::from(reader)).with_metadata_cache_enabled(false);
+ let metadata = reader
+ .get_metadata(None)
.await
.map_err(|error| Error::UnexpectedError {
message: format!("Failed to read the Parquet footer: {error}"),
@@ -2508,7 +2579,16 @@ pub(crate) async fn read_row_count(
impl ArrowFileReader {
fn new(file_size: u64, r: Arc<dyn FileRead>) -> Self {
- Self { file_size, r }
+ Self {
+ file_size,
+ r,
+ metadata_cache_enabled: true,
+ }
+ }
+
+ fn with_metadata_cache_enabled(mut self, enabled: bool) -> Self {
+ self.metadata_cache_enabled = enabled;
+ self
}
fn read_bytes(&mut self, range: Range<u64>) -> BoxFuture<'_,
parquet::errors::Result<Bytes>> {
@@ -2517,6 +2597,26 @@ impl ArrowFileReader {
parquet::errors::ParquetError::External(err_msg.into())
}))
}
+
+ async fn load_metadata(
+ &mut self,
+ options: Option<&ArrowReaderOptions>,
+ ) -> parquet::errors::Result<Arc<ParquetMetaData>> {
+ let metadata_opts = options.map(|o| o.metadata_options().clone());
+ let column_index_policy = options.map(|o| o.column_index_policy());
+ let offset_index_policy = options.map(|o| o.offset_index_policy());
+ let file_size = self.file_size;
+ let mut reader = ParquetMetaDataReader::new()
+ .with_prefetch_hint(Some(METADATA_SIZE_HINT))
+ .with_metadata_options(metadata_opts);
+ if let Some(policy) = column_index_policy {
+ reader = reader.with_column_index_policy(policy);
+ }
+ if let Some(policy) = offset_index_policy {
+ reader = reader.with_offset_index_policy(policy);
+ }
+ Ok(Arc::new(reader.load_and_finish(self, file_size).await?))
+ }
}
impl MetadataFetch for ArrowFileReader {
@@ -2646,28 +2746,24 @@ impl AsyncFileReader for ArrowFileReader {
&mut self,
options: Option<&ArrowReaderOptions>,
) -> BoxFuture<'_, parquet::errors::Result<Arc<ParquetMetaData>>> {
- let metadata_opts = options.map(|o| o.metadata_options().clone());
- // The page-index policies live on `ArrowReaderOptions` directly, not
- // inside `metadata_options`, so they must be forwarded explicitly (the
- // upstream default `AsyncFileReader::get_metadata` does the same).
- // Without this, `with_page_index_policy` would silently no-op here and
- // no page index would ever be loaded.
- let column_index_policy = options.map(|o| o.column_index_policy());
- let offset_index_policy = options.map(|o| o.offset_index_policy());
- let prefetch_hint = Some(METADATA_SIZE_HINT);
+ let options = options.cloned();
+ let metadata_cache_enabled = self.metadata_cache_enabled;
Box::pin(async move {
- let file_size = self.file_size;
- let mut reader = ParquetMetaDataReader::new()
- .with_prefetch_hint(prefetch_hint)
- .with_metadata_options(metadata_opts);
- if let Some(policy) = column_index_policy {
- reader = reader.with_column_index_policy(policy);
+ if !metadata_cache_enabled {
+ return self.load_metadata(options.as_ref()).await;
}
- if let Some(policy) = offset_index_policy {
- reader = reader.with_offset_index_policy(policy);
+ let Some(context) =
self.r.file_format_metadata_cache().and_then(|cache| {
+
cache.downcast_ref::<crate::io::FileFormatMetadataCacheContext>()
+ }) else {
+ return self.load_metadata(options.as_ref()).await;
+ };
+ if context.max_bytes() == 0 {
+ return self.load_metadata(options.as_ref()).await;
}
- let metadata = reader.load_and_finish(self, file_size).await?;
- Ok(Arc::new(metadata))
+ context
+ .get_or_init(ParquetMetadataCache::new)
+ .load(self, options.as_ref())
+ .await
})
}
}
@@ -2808,6 +2904,7 @@ mod tests {
use arrow_buffer::{NullBuffer, OffsetBuffer, ScalarBuffer};
use arrow_schema::{DataType as ArrowDataType, Field as ArrowField, Schema
as ArrowSchema};
use futures::{StreamExt, TryStreamExt};
+ use parquet::arrow::async_reader::AsyncFileReader;
use parquet::basic::{Compression, GzipLevel, ZstdLevel};
use parquet::file::properties::{EnabledStatistics, WriterProperties};
use parquet::file::statistics::Statistics as ParquetStatistics;
@@ -4632,6 +4729,8 @@ mod tests {
ranges: Arc<std::sync::Mutex<Vec<std::ops::Range<u64>>>>,
resident_bytes: Arc<AtomicUsize>,
peak_resident_bytes: Arc<AtomicUsize>,
+ cache_key: Option<Arc<str>>,
+ metadata_cache: Option<Arc<crate::io::FileFormatMetadataCacheContext>>,
}
struct TrackedReadBuffer {
@@ -4659,9 +4758,44 @@ mod tests {
ranges: Arc::new(std::sync::Mutex::new(Vec::new())),
resident_bytes: Arc::new(AtomicUsize::new(0)),
peak_resident_bytes: Arc::new(AtomicUsize::new(0)),
+ cache_key: None,
+ metadata_cache: None,
}
}
+ fn with_cache_key(mut self, cache_key: &str) -> Self {
+ self.cache_key = Some(Arc::from(cache_key));
+ if self.metadata_cache.is_none() {
+ self.metadata_cache = Some(
+
crate::io::FileFormatMetadataCacheContext::from_props(&HashMap::new()).unwrap(),
+ );
+ }
+ self
+ }
+
+ fn with_metadata_cache_max_bytes(mut self, max_bytes: usize) -> Self {
+ self.metadata_cache = Some(
+
crate::io::FileFormatMetadataCacheContext::from_props(&HashMap::from([(
+
crate::common::CatalogOptions::FILE_FORMAT_METADATA_CACHE_MAX_SIZE.to_string(),
+ max_bytes.to_string(),
+ )]))
+ .unwrap(),
+ );
+ self
+ }
+
+ fn with_metadata_cache(
+ mut self,
+ cache: Arc<crate::io::FileFormatMetadataCacheContext>,
+ ) -> Self {
+ self.metadata_cache = Some(cache);
+ self
+ }
+
+ fn read_count(&self) -> usize {
+ self.ranges.lock().unwrap().len()
+ }
+
fn bytes_read(&self) -> u64 {
self.ranges
.lock()
@@ -4695,6 +4829,195 @@ mod tests {
resident_bytes: Arc::clone(&self.resident_bytes),
}))
}
+
+ fn cache_key(&self) -> Option<&str> {
+ self.cache_key.as_deref()
+ }
+
+ fn file_format_metadata_cache(&self) -> Option<&(dyn std::any::Any +
Send + Sync)> {
+ self.metadata_cache
+ .as_ref()
+ .map(|cache| cache.as_ref() as &(dyn std::any::Any + Send +
Sync))
+ }
+ }
+
+ #[tokio::test]
+ async fn parquet_metadata_cache_reuses_metadata_across_readers() {
+ let data = Bytes::from(
+ write_multi_row_group_parquet(8, 64, EnabledStatistics::Chunk,
false).await,
+ );
+ let tracker =
TrackingFileRead::new(data.clone()).with_cache_key("storage-a\0data.parquet");
+
+ for _ in 0..10 {
+ let mut reader =
+ super::ArrowFileReader::new(data.len() as u64,
Arc::new(tracker.clone()));
+ reader.get_metadata(None).await.unwrap();
+ }
+
+ assert_eq!(tracker.read_count(), 1);
+ }
+
+ #[cfg(feature = "storage-fs")]
+ #[tokio::test]
+ async fn parquet_metadata_cache_reuses_metadata_across_file_io_instances()
{
+ let data = Bytes::from(
+ write_multi_row_group_parquet(8, 64, EnabledStatistics::Chunk,
false).await,
+ );
+ let directory = tempfile::tempdir().unwrap();
+ let path = directory.path().join("data.parquet");
+ std::fs::write(&path, &data).unwrap();
+ let path = path.to_string_lossy().into_owned();
+ let context =
+
crate::io::FileFormatMetadataCacheContext::from_props(&HashMap::new()).unwrap();
+ let first = crate::io::FileIO::from_path(&path)
+ .unwrap()
+ .with_file_format_metadata_cache(context.clone())
+ .build()
+ .unwrap();
+ let second = crate::io::FileIO::from_path(&path)
+ .unwrap()
+ .with_file_format_metadata_cache(context)
+ .build()
+ .unwrap();
+
+ let mut first_reader = super::ArrowFileReader::new(
+ data.len() as u64,
+ Arc::new(first.new_input(&path).unwrap().reader().await.unwrap()),
+ );
+ let first_metadata = first_reader.get_metadata(None).await.unwrap();
+ let mut second_reader = super::ArrowFileReader::new(
+ data.len() as u64,
+ Arc::new(second.new_input(&path).unwrap().reader().await.unwrap()),
+ );
+ let second_metadata = second_reader.get_metadata(None).await.unwrap();
+
+ assert!(Arc::ptr_eq(&first_metadata, &second_metadata));
+ }
+
+ #[tokio::test]
+ async fn parquet_metadata_cache_can_be_bypassed() {
+ let data = Bytes::from(
+ write_multi_row_group_parquet(8, 64, EnabledStatistics::Chunk,
false).await,
+ );
+ let tracker =
TrackingFileRead::new(data.clone()).with_cache_key("storage-a\0data.parquet");
+ let unidentified = TrackingFileRead::new(data.clone());
+
+ for _ in 0..2 {
+ let mut reader =
+ super::ArrowFileReader::new(data.len() as u64,
Arc::new(tracker.clone()))
+ .with_metadata_cache_enabled(false);
+ reader.get_metadata(None).await.unwrap();
+ let mut reader =
+ super::ArrowFileReader::new(data.len() as u64,
Arc::new(unidentified.clone()));
+ reader.get_metadata(None).await.unwrap();
+ }
+
+ assert_eq!(tracker.read_count(), 2);
+ assert_eq!(unidentified.read_count(), 2);
+ }
+
+ #[tokio::test]
+ async fn format_table_parquet_metadata_bypasses_cache() {
+ let data = Bytes::from(
+ write_multi_row_group_parquet(8, 64, EnabledStatistics::Chunk,
false).await,
+ );
+ let tracker =
TrackingFileRead::new(data.clone()).with_cache_key("format-table.parquet");
+ let reader = super::ParquetFormatReader::with_options(
+ &HashMap::from([("type".to_string(), "format-table".to_string())]),
+ None,
+ )
+ .unwrap();
+
+ for _ in 0..2 {
+ let mut file_reader =
+ super::ArrowFileReader::new(data.len() as u64,
Arc::new(tracker.clone()));
+ file_reader.metadata_cache_enabled = reader.metadata_cache_enabled;
+ file_reader.get_metadata(None).await.unwrap();
+ }
+
+ assert_eq!(tracker.read_count(), 2);
+ }
+
+ #[tokio::test]
+ async fn parquet_row_count_bypasses_metadata_cache() {
+ let data = Bytes::from(
+ write_multi_row_group_parquet(8, 64, EnabledStatistics::Chunk,
false).await,
+ );
+ let tracker =
TrackingFileRead::new(data.clone()).with_cache_key("row-count.parquet");
+
+ for _ in 0..2 {
+ super::read_row_count(Box::new(tracker.clone()), data.len() as u64)
+ .await
+ .unwrap();
+ }
+
+ assert_eq!(tracker.read_count(), 2);
+ }
+
+ #[tokio::test]
+ async fn parquet_metadata_cache_zero_does_not_affect_other_contexts() {
+ let data = Bytes::from(
+ write_multi_row_group_parquet(8, 64, EnabledStatistics::Chunk,
false).await,
+ );
+ let tracker =
TrackingFileRead::new(data.clone()).with_cache_key("storage-a\0data.parquet");
+
+ let mut reader = super::ArrowFileReader::new(data.len() as u64,
Arc::new(tracker.clone()));
+ reader.get_metadata(None).await.unwrap();
+
+ let disabled = tracker.clone().with_metadata_cache_max_bytes(0);
+ let mut reader = super::ArrowFileReader::new(data.len() as u64,
Arc::new(disabled));
+ reader.get_metadata(None).await.unwrap();
+
+ let mut reader = super::ArrowFileReader::new(data.len() as u64,
Arc::new(tracker.clone()));
+ reader.get_metadata(None).await.unwrap();
+
+ assert_eq!(tracker.read_count(), 2);
+ }
+
+ #[tokio::test]
+ async fn parquet_metadata_cache_coalesces_concurrent_loads() {
+ let data = Bytes::from(
+ write_multi_row_group_parquet(8, 64, EnabledStatistics::Chunk,
false).await,
+ );
+ let tracker =
TrackingFileRead::new(data.clone()).with_cache_key("storage-a\0data.parquet");
+ let loads = (0..10).map(|_| {
+ let tracker = tracker.clone();
+ let data = data.clone();
+ async move {
+ let mut reader = super::ArrowFileReader::new(data.len() as
u64, Arc::new(tracker));
+ reader.get_metadata(None).await
+ }
+ });
+
+ futures::future::try_join_all(loads).await.unwrap();
+
+ assert_eq!(tracker.read_count(), 1);
+ }
+
+ #[tokio::test]
+ async fn parquet_metadata_cache_separates_storage_and_page_index_policy() {
+ let data = Bytes::from(
+ write_multi_row_group_parquet(8, 64, EnabledStatistics::Chunk,
false).await,
+ );
+ let cache =
crate::io::FileFormatMetadataCacheContext::from_props(&HashMap::new()).unwrap();
+ let first = TrackingFileRead::new(data.clone())
+ .with_cache_key("storage-a\0data.parquet")
+ .with_metadata_cache(cache.clone());
+ let second = TrackingFileRead::new(data.clone())
+ .with_cache_key("storage-b\0data.parquet")
+ .with_metadata_cache(cache);
+
+ let mut reader = super::ArrowFileReader::new(data.len() as u64,
Arc::new(first.clone()));
+ reader.get_metadata(None).await.unwrap();
+ let options =
+
super::ArrowReaderOptions::new().with_offset_index_policy(PageIndexPolicy::Optional);
+ let mut reader = super::ArrowFileReader::new(data.len() as u64,
Arc::new(first.clone()));
+ reader.get_metadata(Some(&options)).await.unwrap();
+ let mut reader = super::ArrowFileReader::new(data.len() as u64,
Arc::new(second.clone()));
+ reader.get_metadata(None).await.unwrap();
+
+ assert_eq!(first.read_count(), 2);
+ assert_eq!(second.read_count(), 1);
}
#[tokio::test]
diff --git a/crates/paimon/src/catalog/rest/rest_catalog.rs
b/crates/paimon/src/catalog/rest/rest_catalog.rs
index a7e410cb..de42003d 100644
--- a/crates/paimon/src/catalog/rest/rest_catalog.rs
+++ b/crates/paimon/src/catalog/rest/rest_catalog.rs
@@ -37,6 +37,7 @@ use crate::catalog::{
use crate::common::{CatalogOptions, Options};
use crate::error::Error;
use crate::io::cache::{create_local_cache_with_namespace, LocalCache};
+use crate::io::FileFormatMetadataCacheContext;
use crate::spec::{Partition, PartitionStatistics, Schema, SchemaChange};
use crate::table::{RESTEnv, Table};
use crate::Result;
@@ -60,6 +61,7 @@ pub struct RESTCatalog {
data_token_enabled: bool,
/// Catalog-scoped cache shared by all table FileIO instances.
local_cache: Option<Arc<LocalCache>>,
+ file_format_metadata_cache: Arc<FileFormatMetadataCacheContext>,
}
impl RESTCatalog {
@@ -88,6 +90,8 @@ impl RESTCatalog {
let api_options = api.options().clone();
let local_cache = create_local_cache_with_namespace(&options,
&api_options)?;
+ let file_format_metadata_cache =
+ FileFormatMetadataCacheContext::from_props(api_options.to_map())?;
Ok(Self {
api,
@@ -95,6 +99,7 @@ impl RESTCatalog {
warehouse,
data_token_enabled,
local_cache,
+ file_format_metadata_cache,
})
}
@@ -280,6 +285,7 @@ impl Catalog for RESTCatalog {
self.options.clone(),
self.data_token_enabled,
self.local_cache.clone(),
+ self.file_format_metadata_cache.clone(),
)
.await
}
@@ -297,6 +303,7 @@ impl Catalog for RESTCatalog {
self.options.clone(),
self.data_token_enabled,
self.local_cache.clone(),
+ self.file_format_metadata_cache.clone(),
)
.await
.map(crate::catalog::LoadedTable::Object);
@@ -317,6 +324,7 @@ impl Catalog for RESTCatalog {
self.options.clone(),
self.data_token_enabled,
self.local_cache.clone(),
+ self.file_format_metadata_cache.clone(),
)
.await
.map(|table| crate::catalog::LoadedTable::Paimon(Box::new(table)))
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 d7d17f78..7f106011 100644
--- a/crates/paimon/src/catalog/rest/rest_token_file_io.rs
+++ b/crates/paimon/src/catalog/rest/rest_token_file_io.rs
@@ -31,7 +31,7 @@ use crate::api::rest_util::RESTUtil;
use crate::catalog::Identifier;
use crate::common::{CatalogOptions, Options};
use crate::io::cache::LocalCache;
-use crate::io::{FileIO, FileIOProvider};
+use crate::io::{FileFormatMetadataCacheContext, FileIO, FileIOProvider};
use crate::Result;
use super::rest_token::RESTToken;
@@ -55,6 +55,7 @@ pub struct RESTTokenFileIO {
state: RwLock<Option<TokenState>>,
refresh_lock: Mutex<()>,
local_cache: Option<Arc<LocalCache>>,
+ file_format_metadata_cache: Arc<FileFormatMetadataCacheContext>,
}
impl std::fmt::Debug for RESTTokenFileIO {
@@ -73,6 +74,7 @@ impl RESTTokenFileIO {
catalog_options: Options,
api: Arc<RESTApi>,
local_cache: Option<Arc<LocalCache>>,
+ file_format_metadata_cache: Arc<FileFormatMetadataCacheContext>,
) -> Self {
Self {
identifier,
@@ -82,6 +84,7 @@ impl RESTTokenFileIO {
state: RwLock::new(None),
refresh_lock: Mutex::new(()),
local_cache,
+ file_format_metadata_cache,
}
}
@@ -121,6 +124,7 @@ impl RESTTokenFileIO {
fn build_static_file_io(&self, token: &RESTToken) -> Result<FileIO> {
let merged_props =
RESTUtil::merge(Some(self.catalog_options.to_map()), Some(&token.token));
let mut builder =
FileIO::from_path(&self.path)?.with_props(merged_props);
+ builder =
builder.with_file_format_metadata_cache(self.file_format_metadata_cache.clone());
if let Some(local_cache) = &self.local_cache {
builder = builder.with_local_cache(local_cache.clone());
}
@@ -229,18 +233,24 @@ mod tests {
(options, api, requests, server)
}
+ fn metadata_cache(options: &Options) ->
Arc<FileFormatMetadataCacheContext> {
+ FileFormatMetadataCacheContext::from_props(options.to_map()).unwrap()
+ }
+
#[tokio::test]
async fn test_token_file_io_keeps_catalog_local_cache() {
let table_directory = tempfile::tempdir().unwrap();
let (mut options, api, _, server) = token_api().await;
options.set(CatalogOptions::LOCAL_CACHE_ENABLED, "true");
let local_cache = create_local_cache(&options).unwrap();
+ let metadata_cache = metadata_cache(&options);
let token_file_io = Arc::new(RESTTokenFileIO::new(
Identifier::new("database", "table"),
table_directory.path().to_string_lossy().into_owned(),
options,
api,
local_cache,
+ metadata_cache,
));
let file_io = token_file_io.build_file_io().await.unwrap();
@@ -254,12 +264,14 @@ mod tests {
let table_directory = tempfile::tempdir().unwrap();
let file_path = table_directory.path().join("data");
let (options, api, requests, server) = token_api().await;
+ let metadata_cache = metadata_cache(&options);
let token_file_io = Arc::new(RESTTokenFileIO::new(
Identifier::new("database", "table"),
table_directory.path().to_string_lossy().into_owned(),
options,
api,
None,
+ metadata_cache,
));
let file_io = token_file_io.build_file_io().await.unwrap();
@@ -301,12 +313,14 @@ mod tests {
let file_path = table_directory.path().join("blob");
std::fs::write(&file_path, b"abcdefghij").unwrap();
let (options, api, requests, server) = token_api().await;
+ let metadata_cache = metadata_cache(&options);
let token_file_io = Arc::new(RESTTokenFileIO::new(
Identifier::new("database", "table"),
table_directory.path().to_string_lossy().into_owned(),
options,
api,
None,
+ metadata_cache,
));
let file_io = token_file_io.build_file_io().await.unwrap();
@@ -329,12 +343,14 @@ mod tests {
let file_path = table_directory.path().join("blob");
std::fs::write(&file_path, b"abcdefghij").unwrap();
let (options, api, requests, server) = token_api().await;
+ let metadata_cache = metadata_cache(&options);
let token_file_io = Arc::new(RESTTokenFileIO::new(
Identifier::new("database", "table"),
table_directory.path().to_string_lossy().into_owned(),
options,
api,
None,
+ metadata_cache,
));
let file_io = token_file_io.build_file_io().await.unwrap();
diff --git a/crates/paimon/src/common/options.rs
b/crates/paimon/src/common/options.rs
index 5bb8c121..0110b80c 100644
--- a/crates/paimon/src/common/options.rs
+++ b/crates/paimon/src/common/options.rs
@@ -90,6 +90,10 @@ impl CatalogOptions {
/// Comma-separated file types eligible for local caching.
pub const LOCAL_CACHE_WHITELIST: &'static str = "local-cache.whitelist";
+
+ /// Maximum process-local cache size for parsed file-format metadata.
+ pub const FILE_FORMAT_METADATA_CACHE_MAX_SIZE: &'static str =
+ "file-format.metadata-cache.max-size";
}
/// Configuration options container.
@@ -280,6 +284,10 @@ mod tests {
CatalogOptions::LOCAL_CACHE_WHITELIST,
"local-cache.whitelist"
);
+ assert_eq!(
+ CatalogOptions::FILE_FORMAT_METADATA_CACHE_MAX_SIZE,
+ "file-format.metadata-cache.max-size"
+ );
}
#[test]
diff --git a/crates/paimon/src/io/file_io.rs b/crates/paimon/src/io/file_io.rs
index 8d4b966a..7dba9fe8 100644
--- a/crates/paimon/src/io/file_io.rs
+++ b/crates/paimon/src/io/file_io.rs
@@ -15,13 +15,15 @@
// specific language governing permissions and limitations
// under the License.
+use crate::common::options::{parse_memory_size, CatalogOptions};
use crate::error::*;
+use std::any::Any;
use std::collections::HashMap;
use std::future::Future;
use std::ops::Range;
use std::pin::Pin;
use std::sync::atomic::{AtomicUsize, Ordering};
-use std::sync::Arc;
+use std::sync::{Arc, OnceLock};
use std::task::{Context, Poll};
use std::time::SystemTime;
@@ -92,6 +94,7 @@ pub struct FileIO {
backend: FileIOBackend,
cache: Option<Arc<LocalCache>>,
cache_namespace: usize,
+ file_format_metadata_cache: Arc<FileFormatMetadataCacheContext>,
}
static NEXT_FILE_IO_CACHE_NAMESPACE: AtomicUsize = AtomicUsize::new(1);
@@ -100,11 +103,72 @@ fn next_file_io_cache_namespace() -> usize {
NEXT_FILE_IO_CACHE_NAMESPACE.fetch_add(1, Ordering::Relaxed)
}
+pub(crate) const DEFAULT_FILE_FORMAT_METADATA_CACHE_MAX_BYTES: usize = 50 *
1024 * 1024;
+
+pub(crate) struct FileFormatMetadataCacheContext {
+ max_bytes: usize,
+ cache: OnceLock<Arc<dyn Any + Send + Sync>>,
+}
+
+impl std::fmt::Debug for FileFormatMetadataCacheContext {
+ fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
+ f.debug_struct("FileFormatMetadataCacheContext")
+ .field("max_bytes", &self.max_bytes)
+ .finish_non_exhaustive()
+ }
+}
+
+impl FileFormatMetadataCacheContext {
+ pub(crate) fn from_props(props: &HashMap<String, String>) ->
crate::Result<Arc<Self>> {
+ let max_bytes = props
+ .get(CatalogOptions::FILE_FORMAT_METADATA_CACHE_MAX_SIZE)
+ .map(|value| {
+ parse_memory_size(value)
+ .ok()
+ .and_then(|bytes| usize::try_from(bytes).ok())
+ .ok_or_else(|| Error::ConfigInvalid {
+ message: format!(
+ "Invalid value for {}: {value}",
+ CatalogOptions::FILE_FORMAT_METADATA_CACHE_MAX_SIZE
+ ),
+ })
+ })
+ .transpose()?
+ .unwrap_or(DEFAULT_FILE_FORMAT_METADATA_CACHE_MAX_BYTES);
+ Ok(Arc::new(Self {
+ max_bytes,
+ cache: OnceLock::new(),
+ }))
+ }
+
+ pub(crate) fn max_bytes(&self) -> usize {
+ self.max_bytes
+ }
+
+ pub(crate) fn get_or_init<T>(&self, init: impl FnOnce(usize) -> T) ->
Arc<T>
+ where
+ T: Send + Sync + 'static,
+ {
+ let cache = Arc::clone(
+ self.cache
+ .get_or_init(|| Arc::new(init(self.max_bytes)) as Arc<dyn Any
+ Send + Sync>),
+ );
+ match cache.downcast::<T>() {
+ Ok(cache) => cache,
+ Err(_) => panic!("file-format metadata cache type mismatch"),
+ }
+ }
+}
+
impl std::fmt::Debug for FileIO {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("FileIO")
.field("backend", &self.backend)
.field("cache", &self.cache)
+ .field(
+ "file_format_metadata_cache_max_bytes",
+ &self.file_format_metadata_cache.max_bytes(),
+ )
.finish()
}
}
@@ -138,6 +202,11 @@ impl FileIO {
self
}
+ #[cfg(test)]
+ pub(crate) fn file_format_metadata_cache(&self) ->
Arc<FileFormatMetadataCacheContext> {
+ Arc::clone(&self.file_format_metadata_cache)
+ }
+
pub(crate) fn create_static(&self, path: &str) -> crate::Result<(Operator,
String)> {
let FileIOBackend::Storage(storage) = &self.backend else {
return Err(Error::IoUnsupported {
@@ -221,6 +290,7 @@ impl FileIO {
source: self.file_source(path)?,
path: path.to_string(),
cache_namespace: self.cache_namespace,
+ file_format_metadata_cache:
Arc::clone(&self.file_format_metadata_cache),
cache: self
.cache
.as_ref()
@@ -238,6 +308,7 @@ impl FileIO {
source: self.file_source(path)?,
path: path.to_string(),
cache_namespace: self.cache_namespace,
+ file_format_metadata_cache:
Arc::clone(&self.file_format_metadata_cache),
cache: self
.cache
.as_ref()
@@ -617,6 +688,7 @@ pub struct FileIOBuilder {
scheme_str: Option<String>,
props: HashMap<String, String>,
cache: Option<Arc<LocalCache>>,
+ file_format_metadata_cache: Option<Arc<FileFormatMetadataCacheContext>>,
operator: Option<Operator>,
provider: Option<Arc<dyn FileIOProvider>>,
}
@@ -627,6 +699,7 @@ impl FileIOBuilder {
scheme_str: Some(scheme_str.to_string()),
props: HashMap::default(),
cache: None,
+ file_format_metadata_cache: None,
operator: None,
provider: None,
}
@@ -681,8 +754,21 @@ impl FileIOBuilder {
self
}
+ pub(crate) fn with_file_format_metadata_cache(
+ mut self,
+ cache: Arc<FileFormatMetadataCacheContext>,
+ ) -> Self {
+ self.file_format_metadata_cache = Some(cache);
+ self
+ }
+
pub fn build(mut self) -> crate::Result<FileIO> {
let cache = self.cache.clone();
+ let file_format_metadata_cache = self
+ .file_format_metadata_cache
+ .clone()
+ .map(Ok)
+ .unwrap_or_else(||
FileFormatMetadataCacheContext::from_props(&self.props))?;
let backend = if let Some(provider) = self.provider.take() {
if self.operator.is_some() {
return Err(Error::ConfigInvalid {
@@ -697,6 +783,7 @@ impl FileIOBuilder {
backend,
cache,
cache_namespace: next_file_io_cache_namespace(),
+ file_format_metadata_cache,
})
}
}
@@ -709,6 +796,16 @@ pub trait FileRead: Send + Sync + Unpin + 'static {
fn cache_namespace(&self) -> Option<usize> {
None
}
+
+ /// Stable identity of an immutable file within one storage context.
+ fn cache_key(&self) -> Option<&str> {
+ None
+ }
+
+ #[doc(hidden)]
+ fn file_format_metadata_cache(&self) -> Option<&(dyn Any + Send + Sync)> {
+ None
+ }
}
#[async_trait::async_trait]
@@ -719,22 +816,56 @@ impl FileRead for opendal::Reader {
}
enum InputFileReader {
- Direct(opendal::Reader, usize),
- Cached(CachedFileReader, usize),
+ Direct {
+ reader: opendal::Reader,
+ cache_namespace: usize,
+ cache_key: String,
+ file_format_metadata_cache: Arc<FileFormatMetadataCacheContext>,
+ },
+ Cached {
+ reader: CachedFileReader,
+ cache_namespace: usize,
+ cache_key: String,
+ file_format_metadata_cache: Arc<FileFormatMetadataCacheContext>,
+ },
}
#[async_trait::async_trait]
impl FileRead for InputFileReader {
async fn read(&self, range: Range<u64>) -> crate::Result<Bytes> {
match self {
- Self::Direct(reader, _) => FileRead::read(reader, range).await,
- Self::Cached(reader, _) => FileRead::read(reader, range).await,
+ Self::Direct { reader, .. } => FileRead::read(reader, range).await,
+ Self::Cached { reader, .. } => FileRead::read(reader, range).await,
+ }
+ }
+
+ fn cache_key(&self) -> Option<&str> {
+ match self {
+ Self::Direct { cache_key, .. } | Self::Cached { cache_key, .. } =>
Some(cache_key),
+ }
+ }
+
+ fn file_format_metadata_cache(&self) -> Option<&(dyn Any + Send + Sync)> {
+ match self {
+ Self::Direct {
+ file_format_metadata_cache,
+ ..
+ }
+ | Self::Cached {
+ file_format_metadata_cache,
+ ..
+ } => Some(file_format_metadata_cache.as_ref()),
}
}
fn cache_namespace(&self) -> Option<usize> {
Some(match self {
- Self::Direct(_, namespace) | Self::Cached(_, namespace) =>
*namespace,
+ Self::Direct {
+ cache_namespace, ..
+ }
+ | Self::Cached {
+ cache_namespace, ..
+ } => *cache_namespace,
})
}
}
@@ -874,6 +1005,7 @@ pub struct InputFile {
source: FileSource,
path: String,
cache_namespace: usize,
+ file_format_metadata_cache: Arc<FileFormatMetadataCacheContext>,
cache: Option<Arc<LocalCache>>,
}
@@ -924,7 +1056,12 @@ impl InputFile {
let (op, relative_path, cache_path) =
self.source.resolve(&self.path).await?;
let reader = op.reader(&relative_path).await?;
let Some(cache) = &self.cache else {
- return Ok(InputFileReader::Direct(reader, self.cache_namespace));
+ return Ok(InputFileReader::Direct {
+ reader,
+ cache_namespace: self.cache_namespace,
+ cache_key: cache_path,
+ file_format_metadata_cache:
Arc::clone(&self.file_format_metadata_cache),
+ });
};
let read_token = cache.read_token(&cache_path);
let size = if let Some(size) = cache.file_size(&cache_path,
&read_token).await {
@@ -934,16 +1071,18 @@ impl InputFile {
cache.put_file_size(&cache_path, size, &read_token).await;
size
};
- Ok(InputFileReader::Cached(
- CachedFileReader::new_with_token(
+ Ok(InputFileReader::Cached {
+ reader: CachedFileReader::new_with_token(
Arc::new(reader),
&cache_path,
size,
cache.clone(),
read_token,
),
- self.cache_namespace,
- ))
+ cache_namespace: self.cache_namespace,
+ cache_key: cache_path,
+ file_format_metadata_cache:
Arc::clone(&self.file_format_metadata_cache),
+ })
}
}
@@ -952,6 +1091,7 @@ pub struct OutputFile {
source: FileSource,
path: String,
cache_namespace: usize,
+ file_format_metadata_cache: Arc<FileFormatMetadataCacheContext>,
cache: Option<Arc<LocalCache>>,
}
@@ -971,6 +1111,7 @@ impl OutputFile {
source: self.source,
path: self.path,
cache_namespace: self.cache_namespace,
+ file_format_metadata_cache: self.file_format_metadata_cache,
cache,
}
}
@@ -1736,6 +1877,123 @@ mod input_output_test {
common_test_input_file_partial_read(&file_io,
"memory:/test_file_part_read_mem").await;
}
+ #[tokio::test]
+ async fn test_file_format_metadata_cache_is_scoped_to_file_io_context() {
+ let path = "memory:/cache-key.parquet";
+ let first = setup_memory_file_io();
+ first
+ .new_output(path)
+ .unwrap()
+ .write(Bytes::from_static(b"first"))
+ .await
+ .unwrap();
+ let first_reader =
first.new_input(path).unwrap().reader().await.unwrap();
+ let first_key = first_reader.cache_key().unwrap().to_string();
+ let first_cache = first_reader
+ .file_format_metadata_cache()
+ .unwrap()
+ .downcast_ref::<FileFormatMetadataCacheContext>()
+ .unwrap();
+ let clone_reader = first
+ .clone()
+ .new_input(path)
+ .unwrap()
+ .reader()
+ .await
+ .unwrap();
+ let clone_key = clone_reader.cache_key().unwrap().to_string();
+ let clone_cache = clone_reader
+ .file_format_metadata_cache()
+ .unwrap()
+ .downcast_ref::<FileFormatMetadataCacheContext>()
+ .unwrap();
+
+ let second = setup_memory_file_io();
+ second
+ .new_output(path)
+ .unwrap()
+ .write(Bytes::from_static(b"second"))
+ .await
+ .unwrap();
+ let second_reader =
second.new_input(path).unwrap().reader().await.unwrap();
+ let second_key = second_reader.cache_key().unwrap().to_string();
+ let second_cache = second_reader
+ .file_format_metadata_cache()
+ .unwrap()
+ .downcast_ref::<FileFormatMetadataCacheContext>()
+ .unwrap();
+
+ assert_eq!(first_key, clone_key);
+ assert_ne!(first_key, second_key);
+ assert!(std::ptr::eq(first_cache, clone_cache));
+ assert!(!std::ptr::eq(first_cache, second_cache));
+ }
+
+ #[tokio::test]
+ async fn test_file_format_metadata_cache_size_reaches_file_reader() {
+ let path = "memory:/metadata-cache-size.parquet";
+ let default = setup_memory_file_io();
+ default
+ .new_output(path)
+ .unwrap()
+ .write(Bytes::from_static(b"data"))
+ .await
+ .unwrap();
+ assert_eq!(
+ default
+ .new_input(path)
+ .unwrap()
+ .reader()
+ .await
+ .unwrap()
+ .file_format_metadata_cache()
+ .unwrap()
+ .downcast_ref::<FileFormatMetadataCacheContext>()
+ .unwrap()
+ .max_bytes(),
+ DEFAULT_FILE_FORMAT_METADATA_CACHE_MAX_BYTES
+ );
+
+ let configured = FileIOBuilder::new("memory")
+ .with_prop(CatalogOptions::FILE_FORMAT_METADATA_CACHE_MAX_SIZE, "1
mb")
+ .build()
+ .unwrap();
+ configured
+ .new_output(path)
+ .unwrap()
+ .write(Bytes::from_static(b"data"))
+ .await
+ .unwrap();
+ assert_eq!(
+ configured
+ .new_input(path)
+ .unwrap()
+ .reader()
+ .await
+ .unwrap()
+ .file_format_metadata_cache()
+ .unwrap()
+ .downcast_ref::<FileFormatMetadataCacheContext>()
+ .unwrap()
+ .max_bytes(),
+ 1024 * 1024
+ );
+ }
+
+ #[test]
+ fn test_file_format_metadata_cache_size_rejects_invalid_value() {
+ let error = FileIOBuilder::new("memory")
+ .with_prop(
+ CatalogOptions::FILE_FORMAT_METADATA_CACHE_MAX_SIZE,
+ "invalid",
+ )
+ .build()
+ .unwrap_err();
+ assert!(error
+ .to_string()
+ .contains(CatalogOptions::FILE_FORMAT_METADATA_CACHE_MAX_SIZE));
+ }
+
#[tokio::test]
async fn test_output_file_write_and_read_fs() {
let file_io = setup_fs_file_io();
diff --git a/crates/paimon/src/table/data_file_reader.rs
b/crates/paimon/src/table/data_file_reader.rs
index 009baf49..e608927c 100644
--- a/crates/paimon/src/table/data_file_reader.rs
+++ b/crates/paimon/src/table/data_file_reader.rs
@@ -111,6 +111,14 @@ impl FileRead for TimedFileRead {
fn cache_namespace(&self) -> Option<usize> {
self.inner.cache_namespace()
}
+
+ fn cache_key(&self) -> Option<&str> {
+ self.inner.cache_key()
+ }
+
+ fn file_format_metadata_cache(&self) -> Option<&(dyn std::any::Any + Send
+ Sync)> {
+ self.inner.file_format_metadata_cache()
+ }
}
/// Reads data files through their format-specific readers.
diff --git a/crates/paimon/src/table/rest_env.rs
b/crates/paimon/src/table/rest_env.rs
index af810f08..f518f0e6 100644
--- a/crates/paimon/src/table/rest_env.rs
+++ b/crates/paimon/src/table/rest_env.rs
@@ -23,7 +23,7 @@ use crate::catalog::{Identifier, RESTTokenFileIO};
use crate::common::{CatalogOptions, Options};
use crate::error::Error;
use crate::io::cache::{create_local_cache_with_namespace, LocalCache};
-use crate::io::FileIO;
+use crate::io::{FileFormatMetadataCacheContext, FileIO};
use crate::spec::{CoreOptions, TableSchema, PATH_OPTION};
use crate::table::snapshot_commit::{RESTSnapshotCommit, SnapshotCommit};
use crate::table::{ObjectTable, Table};
@@ -53,6 +53,8 @@ impl Table {
.map(|v| v.eq_ignore_ascii_case("true"))
.unwrap_or(false);
let local_cache = create_local_cache_with_namespace(&rest_options,
api.options())?;
+ let file_format_metadata_cache =
+
FileFormatMetadataCacheContext::from_props(api.options().to_map())?;
RESTEnv::build_table(
&identifier,
response,
@@ -60,6 +62,7 @@ impl Table {
rest_options,
data_token_enabled,
local_cache,
+ file_format_metadata_cache,
)
.await
}
@@ -75,6 +78,7 @@ pub struct RESTEnv {
options: Options,
data_token_enabled: bool,
local_cache: Option<Arc<LocalCache>>,
+ file_format_metadata_cache: Arc<FileFormatMetadataCacheContext>,
}
impl std::fmt::Debug for RESTEnv {
@@ -95,6 +99,7 @@ impl RESTEnv {
options: Options,
data_token_enabled: bool,
local_cache: Option<Arc<LocalCache>>,
+ file_format_metadata_cache: Arc<FileFormatMetadataCacheContext>,
) -> Self {
Self {
identifier,
@@ -103,6 +108,7 @@ impl RESTEnv {
options,
data_token_enabled,
local_cache,
+ file_format_metadata_cache,
}
}
@@ -134,6 +140,7 @@ impl RESTEnv {
self.options.clone(),
self.data_token_enabled,
self.local_cache.clone(),
+ self.file_format_metadata_cache.clone(),
)
.await
}
@@ -145,6 +152,7 @@ impl RESTEnv {
options: Options,
data_token_enabled: bool,
local_cache: Option<Arc<LocalCache>>,
+ file_format_metadata_cache: Arc<FileFormatMetadataCacheContext>,
) -> Result<Table> {
let response = Self::fetch_table_response(identifier, &api).await?;
Self::build_table(
@@ -154,6 +162,7 @@ impl RESTEnv {
options,
data_token_enabled,
local_cache,
+ file_format_metadata_cache,
)
.await
}
@@ -177,6 +186,7 @@ impl RESTEnv {
options: Options,
data_token_enabled: bool,
local_cache: Option<Arc<LocalCache>>,
+ file_format_metadata_cache: Arc<FileFormatMetadataCacheContext>,
) -> Result<Table> {
let identifier = response_identifier(identifier, &response)?;
let schema = response.schema.ok_or_else(|| Error::DataInvalid {
@@ -240,9 +250,9 @@ impl RESTEnv {
&table_path,
api.clone(),
&options,
- data_token_enabled,
- is_external,
+ data_token_enabled && !is_external,
local_cache.clone(),
+ file_format_metadata_cache.clone(),
)
.await?;
@@ -253,6 +263,7 @@ impl RESTEnv {
options,
data_token_enabled,
local_cache,
+ file_format_metadata_cache,
);
let parsed_identifier = identifier.parsed_object_name()?;
let branch = parsed_identifier.branch_or_default().to_string();
@@ -277,6 +288,7 @@ impl RESTEnv {
options: Options,
data_token_enabled: bool,
local_cache: Option<Arc<LocalCache>>,
+ file_format_metadata_cache: Arc<FileFormatMetadataCacheContext>,
) -> Result<ObjectTable> {
let identifier = response_identifier(identifier, &response)?;
let schema = response.schema.ok_or_else(|| Error::DataInvalid {
@@ -318,9 +330,9 @@ impl RESTEnv {
&object_path,
api,
&options,
- data_token_enabled,
- is_external,
+ data_token_enabled && !is_external,
local_cache,
+ file_format_metadata_cache,
)
.await?;
@@ -332,23 +344,25 @@ impl RESTEnv {
path: &str,
api: Arc<RESTApi>,
options: &Options,
- data_token_enabled: bool,
- is_external: bool,
+ use_data_token: bool,
local_cache: Option<Arc<LocalCache>>,
+ file_format_metadata_cache: Arc<FileFormatMetadataCacheContext>,
) -> Result<FileIO> {
- if data_token_enabled && !is_external {
+ if use_data_token {
return Arc::new(RESTTokenFileIO::new(
identifier.clone(),
path.to_string(),
options.clone(),
api,
local_cache,
+ file_format_metadata_cache,
))
.build_file_io()
.await;
}
let mut builder =
FileIO::from_path(path)?.with_props(options.to_map());
+ builder =
builder.with_file_format_metadata_cache(file_format_metadata_cache);
if let Some(local_cache) = local_cache {
builder = builder.with_local_cache(local_cache);
}
@@ -488,6 +502,7 @@ mod tests {
);
let local_cache = create_local_cache(&options).unwrap();
let api = Arc::new(RESTApi::new(options.clone(),
false).await.unwrap());
+ let metadata_cache =
FileFormatMetadataCacheContext::from_props(options.to_map()).unwrap();
let rest_env = RESTEnv::new(
Identifier::new("database", "table"),
@@ -496,9 +511,50 @@ mod tests {
options,
false,
local_cache,
+ metadata_cache,
);
assert!(rest_env.has_local_cache());
assert!(rest_env.clone().has_local_cache());
}
+
+ #[tokio::test]
+ async fn test_rest_table_loads_share_catalog_metadata_cache() {
+ let mut options = Options::new();
+ options.set(CatalogOptions::URI, "http://localhost:1");
+ options.set(CatalogOptions::WAREHOUSE, "test-warehouse");
+ options.set(CatalogOptions::TOKEN_PROVIDER, "bear");
+ options.set(CatalogOptions::TOKEN, "test-token");
+ let api = Arc::new(RESTApi::new(options.clone(),
false).await.unwrap());
+ let metadata_cache =
FileFormatMetadataCacheContext::from_props(options.to_map()).unwrap();
+ let identifier = Identifier::new("database", "table");
+
+ let first = RESTEnv::build_file_io(
+ &identifier,
+ "memory:/table",
+ api.clone(),
+ &options,
+ false,
+ None,
+ metadata_cache.clone(),
+ )
+ .await
+ .unwrap();
+ let second = RESTEnv::build_file_io(
+ &identifier,
+ "memory:/table",
+ api,
+ &options,
+ false,
+ None,
+ metadata_cache,
+ )
+ .await
+ .unwrap();
+
+ assert!(Arc::ptr_eq(
+ &first.file_format_metadata_cache(),
+ &second.file_format_metadata_cache()
+ ));
+ }
}
diff --git a/crates/paimon/src/table/snapshot_manager.rs
b/crates/paimon/src/table/snapshot_manager.rs
index dbc76397..fc1b681e 100644
--- a/crates/paimon/src/table/snapshot_manager.rs
+++ b/crates/paimon/src/table/snapshot_manager.rs
@@ -586,7 +586,17 @@ mod tests {
let server = tokio::spawn(async move { axum::serve(listener,
app).await.unwrap() });
let api = Arc::new(RESTApi::new(options.clone(),
false).await.unwrap());
let id = Identifier::new("database", "table");
- let env = crate::table::RESTEnv::new(id.clone(), "uuid".into(), api,
options, false, None);
+ let metadata_cache =
+
crate::io::FileFormatMetadataCacheContext::from_props(options.to_map()).unwrap();
+ let env = crate::table::RESTEnv::new(
+ id.clone(),
+ "uuid".into(),
+ api,
+ options,
+ false,
+ None,
+ metadata_cache,
+ );
let (io, manager) = setup("/rest-table").await;
manager.commit_snapshot(&test_snapshot(2)).await.unwrap();
let schema = TableSchema::new(
diff --git a/crates/paimon/src/table/table_commit/parity_tests.rs
b/crates/paimon/src/table/table_commit/parity_tests.rs
index bdd9c4d2..3c2ecbac 100644
--- a/crates/paimon/src/table/table_commit/parity_tests.rs
+++ b/crates/paimon/src/table/table_commit/parity_tests.rs
@@ -614,8 +614,17 @@ async fn
rest_commit_uses_catalog_snapshot_schema_and_retry_identity() {
let server = tokio::spawn(async move { axum::serve(listener,
app).await.unwrap() });
let api = Arc::new(RESTApi::new(options.clone(), false).await.unwrap());
let identifier = Identifier::new("database", "table");
- let env =
- crate::table::RESTEnv::new(identifier.clone(), "uuid".into(), api,
options, false, None);
+ let metadata_cache =
+
crate::io::FileFormatMetadataCacheContext::from_props(options.to_map()).unwrap();
+ let env = crate::table::RESTEnv::new(
+ identifier.clone(),
+ "uuid".into(),
+ api,
+ options,
+ false,
+ None,
+ metadata_cache,
+ );
let table = Table::new(
io.clone(),
identifier,
diff --git a/crates/paimon/src/table/table_commit/recovery_tests.rs
b/crates/paimon/src/table/table_commit/recovery_tests.rs
index b202f100..ffaaae51 100644
--- a/crates/paimon/src/table/table_commit/recovery_tests.rs
+++ b/crates/paimon/src/table/table_commit/recovery_tests.rs
@@ -546,8 +546,17 @@ async fn
rest_delete_writer_pins_catalog_snapshot_and_preserves_vectors() {
let server = tokio::spawn(async move { axum::serve(listener,
app).await.unwrap() });
let api = Arc::new(RESTApi::new(options.clone(), false).await.unwrap());
let identifier = Identifier::new("database", "table");
- let env =
- crate::table::RESTEnv::new(identifier.clone(), "uuid".into(), api,
options, false, None);
+ let metadata_cache =
+
crate::io::FileFormatMetadataCacheContext::from_props(options.to_map()).unwrap();
+ let env = crate::table::RESTEnv::new(
+ identifier.clone(),
+ "uuid".into(),
+ api,
+ options,
+ false,
+ None,
+ metadata_cache,
+ );
let table = Table::new(io.clone(), identifier, path.into(), schema,
Some(env));
let mut commit = TableCommit::new(table.clone(), "rest-writer".into());
commit.commit_min_retry_wait_ms = 0;