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 dc049cb9 perf(core): cache ROW sidecar size and reader metadata (#1196)
dc049cb9 is described below
commit dc049cb906d056a7350b3e76f450df0ed32371e0
Author: XiaoHongbo <[email protected]>
AuthorDate: Sat Oct 24 10:24:37 2026 +0800
perf(core): cache ROW sidecar size and reader metadata (#1196)
---
crates/paimon/src/arrow/format/mod.rs | 2 +-
crates/paimon/src/arrow/format/row.rs | 418 +++++++++++++++++++++++++++------
crates/paimon/src/io/file_io.rs | 320 ++++++++++++++++++++++++-
crates/paimon/src/table/row_sidecar.rs | 4 +-
4 files changed, 670 insertions(+), 74 deletions(-)
diff --git a/crates/paimon/src/arrow/format/mod.rs
b/crates/paimon/src/arrow/format/mod.rs
index 3f12a841..3de32c7e 100644
--- a/crates/paimon/src/arrow/format/mod.rs
+++ b/crates/paimon/src/arrow/format/mod.rs
@@ -411,7 +411,7 @@ pub(crate) fn create_format_reader_with_budget(
} else if lower.ends_with(".avro") {
Box::new(avro::AvroFormatReader)
} else if lower.ends_with(".row") {
- Box::new(row::RowFormatReader)
+ Box::new(row::RowFormatReader::with_options(table_options))
} else if lower.ends_with(".mosaic") {
Box::new(mosaic::MosaicFormatReader::with_prefetch(mosaic_prefetch))
} else {
diff --git a/crates/paimon/src/arrow/format/row.rs
b/crates/paimon/src/arrow/format/row.rs
index ff53b619..3294fdf6 100644
--- a/crates/paimon/src/arrow/format/row.rs
+++ b/crates/paimon/src/arrow/format/row.rs
@@ -20,13 +20,14 @@
//! Layout reference:
//! `org.apache.paimon.format.row.RowFormatWriter` in paimon-java.
+use super::metadata_cache::FileMetadataCache;
use super::{FilePredicates, FormatFileReader, FormatFileWriter,
FormatWriteResult};
use crate::arrow::{
arrow_to_paimon_type, build_target_arrow_schema, is_variant_arrow_fields,
paimon_type_to_arrow,
variant_arrow_type,
};
-use crate::io::{FileRead, FileWrite, OutputFile};
-use crate::spec::{DataField, DataType, IntType, VarBinaryType, VariantType};
+use crate::io::{FileFormatMetadataCacheContext, FileRead, FileWrite,
OutputFile};
+use crate::spec::{CoreOptions, DataField, DataType, IntType, VarBinaryType,
VariantType};
use crate::table::{ArrowRecordBatchStream, RowRange};
use crate::Error;
use arrow_array::builder::{
@@ -48,9 +49,29 @@ use async_stream::try_stream;
use async_trait::async_trait;
use bytes::Bytes;
use futures::StreamExt;
+use std::collections::HashMap;
use std::sync::Arc;
-pub(crate) struct RowFormatReader;
+pub(crate) struct RowFormatReader {
+ metadata_cache_enabled: bool,
+}
+
+impl Default for RowFormatReader {
+ fn default() -> Self {
+ Self {
+ metadata_cache_enabled: true,
+ }
+ }
+}
+
+impl RowFormatReader {
+ pub(crate) fn with_options(options: &HashMap<String, String>) -> Self {
+ Self {
+ // Format-table files may be replaced in place, even with the same
size.
+ metadata_cache_enabled:
!CoreOptions::new(options).is_format_table(),
+ }
+ }
+}
pub(crate) struct RowFormatWriter {
writer: Box<dyn FileWrite>,
@@ -71,6 +92,57 @@ const ROW_BLOCK_READ_CONCURRENCY: usize = 8;
const FOOTER_SIZE: u64 = 32;
const ROW_MAGIC: i32 = 0x524F5753;
const ROW_VERSION: u8 = 1;
+const ROW_METADATA_CACHE_MAX_ENTRIES: usize = 4096;
+
+#[derive(Clone, Debug, Eq, Hash, PartialEq)]
+struct RowMetadataCacheKey {
+ file: String,
+ size: u64,
+}
+
+struct RowFileMetadata {
+ index: RowBlockIndex,
+ total_rows: usize,
+}
+
+struct RowMetadataCache {
+ inner: FileMetadataCache<RowMetadataCacheKey, RowFileMetadata>,
+}
+
+impl RowMetadataCache {
+ fn new(max_bytes: usize) -> Self {
+ Self {
+ inner: FileMetadataCache::new(max_bytes,
ROW_METADATA_CACHE_MAX_ENTRIES),
+ }
+ }
+
+ async fn load(
+ &self,
+ reader: &dyn FileRead,
+ file_size: u64,
+ ) -> crate::Result<Arc<RowFileMetadata>> {
+ let key = reader
+ .cache_key()
+ .filter(|key| !key.is_empty())
+ .map(|file| RowMetadataCacheKey {
+ file: file.to_string(),
+ // ROW sidecars are immutable; size separates supported
replacements.
+ size: file_size,
+ });
+ 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,
+ || load_row_file_metadata(reader, file_size),
+ |metadata| {
+ std::mem::size_of::<RowFileMetadata>()
+ .saturating_add(metadata.index.heap_bytes())
+ },
+ )
+ .await
+ }
+}
impl RowFormatWriter {
pub(crate) async fn new(
@@ -254,56 +326,19 @@ impl FormatFileReader for RowFormatReader {
});
}
- let footer_start = file_size - FOOTER_SIZE;
- let footer_bytes = reader.read(footer_start..file_size).await?;
- let footer = RowFileFooter::from_bytes(footer_bytes.as_ref())?;
- if footer.index_offset < 0 || footer.index_length < 0 {
- return Err(Error::DataInvalid {
- message: format!(
- "Invalid .row footer index offset/length: {}/{}",
- footer.index_offset, footer.index_length
- ),
- source: None,
- });
- }
- let index_start = footer.index_offset as u64;
- let index_end = index_start
- .checked_add(footer.index_length as u64)
- .ok_or_else(|| Error::DataInvalid {
- message: ".row index range overflows u64".to_string(),
- source: None,
- })?;
- if index_end > footer_start {
- return Err(Error::DataInvalid {
- message: format!(
- ".row index range [{index_start}, {index_end}) exceeds
footer start {footer_start}"
- ),
- source: None,
- });
- }
- let index_bytes = reader.read(index_start..index_end).await?;
- let index = RowBlockIndex::from_bytes(index_bytes.as_ref())?;
- let footer_block_count =
- usize::try_from(footer.block_count).map_err(|e| Error::DataInvalid
{
- message: format!("Invalid .row footer block count {}",
footer.block_count),
- source: Some(Box::new(e)),
- })?;
- if index.block_count() != footer_block_count {
- return Err(Error::DataInvalid {
- message: format!(
- ".row footer block count {} does not match index block
count {}",
- footer.block_count,
- index.block_count()
- ),
- source: None,
- });
- }
- let total_rows =
- usize::try_from(footer.total_row_count).map_err(|e|
Error::DataInvalid {
- message: format!("Invalid .row total row count {}",
footer.total_row_count),
- source: Some(Box::new(e)),
- })?;
- index.validate_for_file(total_rows, index_start)?;
+ let cache = if self.metadata_cache_enabled {
+ reader
+ .file_format_metadata_cache()
+ .and_then(|cache|
cache.downcast_ref::<FileFormatMetadataCacheContext>())
+ .and_then(|context|
context.row_metadata_get_or_init(RowMetadataCache::new))
+ } else {
+ None
+ };
+ let metadata = match cache {
+ Some(cache) => cache.load(reader.as_ref(), file_size).await?,
+ None => load_row_file_metadata(reader.as_ref(), file_size).await?,
+ };
+ let total_rows = metadata.total_rows;
validate_row_selection(total_rows, row_selection.as_deref())?;
// Public callers may supply unordered or overlapping ranges. Normalize
// once so every block can seek directly to its overlapping window.
@@ -313,15 +348,16 @@ impl FormatFileReader for RowFormatReader {
let row_type = read_fields.to_vec();
let batch_size = batch_size.unwrap_or(DEFAULT_BATCH_SIZE);
- let blocks_to_read = blocks_to_read(&index, total_rows,
row_selection.as_deref());
+ let blocks_to_read = blocks_to_read(&metadata.index, total_rows,
row_selection.as_deref());
let predicates = predicates.map(|fp| FilePredicates {
predicates: fp.predicates.clone(),
row_filter_factory: None,
file_fields: fp.file_fields.clone(),
});
Ok(try_stream! {
+ let index = &metadata.index;
let mut blocks =
futures::stream::iter(blocks_to_read.into_iter().map(|block_idx| {
- read_row_block(reader.as_ref(), &index, block_idx)
+ read_row_block(reader.as_ref(), index, block_idx)
}))
.buffered(ROW_BLOCK_READ_CONCURRENCY);
@@ -354,6 +390,62 @@ impl FormatFileReader for RowFormatReader {
}
}
+async fn load_row_file_metadata(
+ reader: &dyn FileRead,
+ file_size: u64,
+) -> crate::Result<Arc<RowFileMetadata>> {
+ let footer_start = file_size - FOOTER_SIZE;
+ let footer_bytes = reader.read(footer_start..file_size).await?;
+ let footer = RowFileFooter::from_bytes(footer_bytes.as_ref())?;
+ if footer.index_offset < 0 || footer.index_length < 0 {
+ return Err(Error::DataInvalid {
+ message: format!(
+ "Invalid .row footer index offset/length: {}/{}",
+ footer.index_offset, footer.index_length
+ ),
+ source: None,
+ });
+ }
+ let index_start = footer.index_offset as u64;
+ let index_end = index_start
+ .checked_add(footer.index_length as u64)
+ .ok_or_else(|| Error::DataInvalid {
+ message: ".row index range overflows u64".to_string(),
+ source: None,
+ })?;
+ if index_end > footer_start {
+ return Err(Error::DataInvalid {
+ message: format!(
+ ".row index range [{index_start}, {index_end}) exceeds footer
start {footer_start}"
+ ),
+ source: None,
+ });
+ }
+ let index_bytes = reader.read(index_start..index_end).await?;
+ let index = RowBlockIndex::from_bytes(index_bytes.as_ref())?;
+ let footer_block_count =
+ usize::try_from(footer.block_count).map_err(|e| Error::DataInvalid {
+ message: format!("Invalid .row footer block count {}",
footer.block_count),
+ source: Some(Box::new(e)),
+ })?;
+ if index.block_count() != footer_block_count {
+ return Err(Error::DataInvalid {
+ message: format!(
+ ".row footer block count {} does not match index block count
{}",
+ footer.block_count,
+ index.block_count()
+ ),
+ source: None,
+ });
+ }
+ let total_rows = usize::try_from(footer.total_row_count).map_err(|e|
Error::DataInvalid {
+ message: format!("Invalid .row total row count {}",
footer.total_row_count),
+ source: Some(Box::new(e)),
+ })?;
+ index.validate_for_file(total_rows, index_start)?;
+ Ok(Arc::new(RowFileMetadata { index, total_rows }))
+}
+
struct RowBlockPayload {
block_idx: usize,
data: Vec<u8>,
@@ -1980,6 +2072,15 @@ struct RowBlockIndex {
}
impl RowBlockIndex {
+ fn heap_bytes(&self) -> usize {
+ self.block_offsets
+ .capacity()
+ .saturating_add(self.block_compressed_sizes.capacity())
+ .saturating_add(self.block_uncompressed_sizes.capacity())
+ .saturating_add(self.block_row_starts.capacity())
+ .saturating_mul(std::mem::size_of::<i64>())
+ }
+
fn new(
block_compressed_sizes: Vec<i64>,
block_uncompressed_sizes: Vec<i64>,
@@ -2468,6 +2569,7 @@ mod tests {
};
use crate::variant::GenericVariant;
use futures::TryStreamExt;
+ use std::collections::HashMap;
use std::ops::Range;
use std::sync::atomic::{AtomicUsize, Ordering};
use std::time::Duration;
@@ -2563,7 +2665,7 @@ mod tests {
let input = file_io.new_input(path).unwrap();
let bytes = input.read().await.unwrap();
- let batches = RowFormatReader
+ let batches = RowFormatReader::default()
.read_batch_stream(
Box::new(BytesFileRead(bytes.clone())),
bytes.len() as u64,
@@ -2641,7 +2743,7 @@ mod tests {
let input = file_io.new_input(path).unwrap();
let bytes = input.read().await.unwrap();
// read_fields is the FULL physical schema, as DataFileReader passes
for `.row`.
- let batches = RowFormatReader
+ let batches = RowFormatReader::default()
.read_batch_stream(
Box::new(BytesFileRead(bytes.clone())),
bytes.len() as u64,
@@ -2730,7 +2832,7 @@ mod tests {
let input = file_io.new_input(path).unwrap();
let bytes = input.read().await.unwrap();
- let batches = RowFormatReader
+ let batches = RowFormatReader::default()
.read_batch_stream(
Box::new(BytesFileRead(bytes.clone())),
bytes.len() as u64,
@@ -2803,7 +2905,7 @@ mod tests {
let input = file_io.new_input(path).unwrap();
let bytes = input.read().await.unwrap();
- let actual = RowFormatReader
+ let actual = RowFormatReader::default()
.read_batch_stream(
Box::new(BytesFileRead(bytes.clone())),
bytes.len() as u64,
@@ -2863,7 +2965,7 @@ mod tests {
Box::new(writer).close().await.unwrap();
let bytes = file_io.new_input(path).unwrap().read().await.unwrap();
- let batches = RowFormatReader
+ let batches = RowFormatReader::default()
.read_batch_stream(
Box::new(BytesFileRead(bytes.clone())),
bytes.len() as u64,
@@ -2944,7 +3046,7 @@ mod tests {
Box::new(writer).close().await.unwrap();
let bytes = file_io.new_input(path).unwrap().read().await.unwrap();
- let batches = RowFormatReader
+ let batches = RowFormatReader::default()
.read_batch_stream(
Box::new(BytesFileRead(bytes.clone())),
bytes.len() as u64,
@@ -3048,7 +3150,7 @@ mod tests {
Box::new(writer).close().await.unwrap();
let bytes = file_io.new_input(path).unwrap().read().await.unwrap();
- let batches = RowFormatReader
+ let batches = RowFormatReader::default()
.read_batch_stream(
Box::new(BytesFileRead(bytes.clone())),
bytes.len() as u64,
@@ -3212,7 +3314,7 @@ mod tests {
let bytes = file_io.new_input(path).unwrap().read().await.unwrap();
let reader = TrackingFileRead::new(bytes.clone());
- let batches = RowFormatReader
+ let batches = RowFormatReader::default()
.read_batch_stream(
Box::new(reader.clone()),
bytes.len() as u64,
@@ -3391,7 +3493,7 @@ mod tests {
Box::new(writer).close().await.unwrap();
let bytes = file_io.new_input(path).unwrap().read().await.unwrap();
- let batches = RowFormatReader
+ let batches = RowFormatReader::default()
.read_batch_stream(
Box::new(BytesFileRead(bytes.clone())),
bytes.len() as u64,
@@ -3581,12 +3683,196 @@ mod tests {
.is_err());
}
+ struct CountingFileRead {
+ bytes: Bytes,
+ key: Option<String>,
+ context: Arc<FileFormatMetadataCacheContext>,
+ reads: Arc<AtomicUsize>,
+ }
+
+ #[async_trait::async_trait]
+ impl FileRead for CountingFileRead {
+ async fn read(&self, range: Range<u64>) -> crate::Result<Bytes> {
+ self.reads.fetch_add(1, Ordering::SeqCst);
+ Ok(self.bytes.slice(range.start as usize..range.end as usize))
+ }
+
+ fn cache_key(&self) -> Option<&str> {
+ self.key.as_deref()
+ }
+
+ fn file_format_metadata_cache(&self) -> Option<&(dyn std::any::Any +
Send + Sync)> {
+ Some(self.context.as_ref())
+ }
+ }
+
+ async fn read_counted_fixture(
+ context: Arc<FileFormatMetadataCacheContext>,
+ key: Option<&str>,
+ reads: Arc<AtomicUsize>,
+ ) {
+ let bytes = java_fixture_bytes();
+ let batches = RowFormatReader::default()
+ .read_batch_stream(
+ Box::new(CountingFileRead {
+ bytes: bytes.clone(),
+ key: key.map(str::to_string),
+ context,
+ reads,
+ }),
+ bytes.len() as u64,
+ &java_fixture_fields(),
+ None,
+ Some(8),
+ None,
+ )
+ .await
+ .unwrap()
+ .try_collect::<Vec<_>>()
+ .await
+ .unwrap();
+ assert_eq!(batches.iter().map(RecordBatch::num_rows).sum::<usize>(),
2);
+ }
+
+ #[tokio::test]
+ async fn row_reader_caches_footer_and_index_by_file_io_context() {
+ let enabled =
FileFormatMetadataCacheContext::from_props(&HashMap::new()).unwrap();
+ let reads = Arc::new(AtomicUsize::new(0));
+ let key = Some("storage-A/data.row");
+ read_counted_fixture(enabled.clone(), key, reads.clone()).await;
+ assert_eq!(reads.load(Ordering::SeqCst), 3); // footer, index, block
+ read_counted_fixture(enabled.clone(), key, reads.clone()).await;
+ assert_eq!(reads.load(Ordering::SeqCst), 4); // block only
+
+ let other =
FileFormatMetadataCacheContext::from_props(&HashMap::new()).unwrap();
+ let other_reads = Arc::new(AtomicUsize::new(0));
+ read_counted_fixture(other, key, other_reads.clone()).await;
+ assert_eq!(other_reads.load(Ordering::SeqCst), 3);
+
+ let other_key_reads = Arc::new(AtomicUsize::new(0));
+ read_counted_fixture(
+ enabled.clone(),
+ Some("storage-B/data.row"),
+ other_key_reads.clone(),
+ )
+ .await;
+ assert_eq!(other_key_reads.load(Ordering::SeqCst), 3);
+
+ let unkeyed_reads = Arc::new(AtomicUsize::new(0));
+ read_counted_fixture(enabled.clone(), None,
unkeyed_reads.clone()).await;
+ read_counted_fixture(enabled, None, unkeyed_reads.clone()).await;
+ assert_eq!(unkeyed_reads.load(Ordering::SeqCst), 6);
+
+ let disabled =
FileFormatMetadataCacheContext::from_props(&HashMap::from([(
+
crate::common::options::CatalogOptions::FILE_FORMAT_METADATA_CACHE_MAX_SIZE.to_string(),
+ "0".to_string(),
+ )]))
+ .unwrap();
+ let disabled_reads = Arc::new(AtomicUsize::new(0));
+ read_counted_fixture(disabled.clone(), key,
disabled_reads.clone()).await;
+ read_counted_fixture(disabled, key, disabled_reads.clone()).await;
+ assert_eq!(disabled_reads.load(Ordering::SeqCst), 6);
+ }
+
+ fn one_string_row_file(value: &str) -> Bytes {
+ let fields = vec![DataField::new(
+ 0,
+ "value".to_string(),
+ DataType::VarChar(VarCharType::new(256).unwrap()),
+ )];
+ let schema = build_target_arrow_schema(&fields).unwrap();
+ let batch =
+ RecordBatch::try_new(schema,
vec![Arc::new(StringArray::from(vec![value]))]).unwrap();
+ let mut block_writer = RowBlockWriter::new();
+ block_writer.write_row(&batch, 0, &fields).unwrap();
+ let block = block_writer.finish();
+ let compressed = zstd::bulk::compress(&block, 1).unwrap();
+ let index = RowBlockIndex::new(
+ vec![compressed.len() as i64],
+ vec![block.len() as i64],
+ vec![0],
+ )
+ .unwrap()
+ .to_bytes()
+ .unwrap();
+ let footer = RowFileFooter {
+ total_row_count: 1,
+ block_count: 1,
+ index_offset: compressed.len() as i64,
+ index_length: index.len() as i32,
+ };
+ let mut file = compressed;
+ file.extend_from_slice(&index);
+ file.extend_from_slice(&footer.to_bytes());
+ Bytes::from(file)
+ }
+
+ #[tokio::test]
+ async fn
format_table_row_metadata_bypasses_cache_after_same_size_replacement() {
+ let old_value = "a".repeat(100);
+ let new_value = "a".repeat(101);
+ let old_file = one_string_row_file(&old_value);
+ let new_file = one_string_row_file(&new_value);
+ assert_eq!(old_file.len(), new_file.len());
+
+ let fields = vec![DataField::new(
+ 0,
+ "value".to_string(),
+ DataType::VarChar(VarCharType::new(256).unwrap()),
+ )];
+ let context =
FileFormatMetadataCacheContext::from_props(&HashMap::new()).unwrap();
+ let reads = Arc::new(AtomicUsize::new(0));
+ for (file, expected) in [(old_file, old_value), (new_file, new_value)]
{
+ let configured = super::super::create_format_reader_with_budget(
+ "data.row",
+ false,
+ super::super::FormatReadFields {
+ data_schema: &fields,
+ projected: &fields,
+ },
+ &HashMap::from([("type".to_string(),
"format-table".to_string())]),
+ None,
+ super::super::blob::DEFAULT_BLOB_READ_PARALLELISM,
+ super::super::MosaicPrefetchOptions::default(),
+ )
+ .unwrap();
+ let batches = configured
+ .reader
+ .read_batch_stream(
+ Box::new(CountingFileRead {
+ bytes: file.clone(),
+ key: Some("same-path/data.row".to_string()),
+ context: context.clone(),
+ reads: reads.clone(),
+ }),
+ file.len() as u64,
+ &fields,
+ None,
+ Some(8),
+ None,
+ )
+ .await
+ .unwrap()
+ .try_collect::<Vec<_>>()
+ .await
+ .unwrap();
+ assert_eq!(batches.len(), 1);
+ let values = batches[0]
+ .column(0)
+ .as_any()
+ .downcast_ref::<StringArray>()
+ .unwrap();
+ assert_eq!(values.value(0), expected);
+ }
+ assert_eq!(reads.load(Ordering::SeqCst), 6); // footer, index, block
each time
+ }
+
#[tokio::test]
async fn row_reader_reads_java_generated_fixture() {
let fields = java_fixture_fields();
let bytes = java_fixture_bytes();
let file_size = bytes.len() as u64;
- let batches = RowFormatReader
+ let batches = RowFormatReader::default()
.read_batch_stream(
Box::new(BytesFileRead(bytes.clone())),
file_size,
@@ -3937,7 +4223,7 @@ mod tests {
.await
.unwrap();
- let batches = RowFormatReader
+ let batches = RowFormatReader::default()
.read_batch_stream(
Box::new(BytesFileRead(bytes.clone())),
bytes.len() as u64,
diff --git a/crates/paimon/src/io/file_io.rs b/crates/paimon/src/io/file_io.rs
index 9369cb74..301294fa 100644
--- a/crates/paimon/src/io/file_io.rs
+++ b/crates/paimon/src/io/file_io.rs
@@ -17,12 +17,14 @@
use crate::common::options::{parse_memory_size, CatalogOptions};
use crate::error::*;
+use lru::LruCache;
use std::any::Any;
use std::collections::HashMap;
use std::future::Future;
+use std::num::NonZeroUsize;
use std::ops::Range;
use std::pin::Pin;
-use std::sync::{Arc, OnceLock};
+use std::sync::{Arc, Mutex, OnceLock};
use std::task::{Context, Poll};
use std::time::SystemTime;
@@ -113,10 +115,17 @@ pub struct FileIO {
pub(crate) const DEFAULT_FILE_FORMAT_METADATA_CACHE_MAX_BYTES: usize = 50 *
1024 * 1024;
pub(crate) const DEFAULT_BLOB_INDEX_CACHE_MAX_BYTES: usize = 64 * 1024 * 1024;
+const ROW_METADATA_CACHE_MAX_BYTES: usize = 32 * 1024 * 1024;
+// Keep the combined parsed Parquet and ROW metadata below the configured
limit.
+const ROW_METADATA_CACHE_BUDGET_DIVISOR: usize = 4;
+const ROW_SIDECAR_SIZE_CACHE_MAX_ENTRIES: usize = 4096;
pub(crate) struct FileFormatMetadataCacheContext {
max_bytes: usize,
cache: OnceLock<Arc<dyn Any + Send + Sync>>,
+ row_metadata_cache: OnceLock<Arc<dyn Any + Send + Sync>>,
+ process_id: u32,
+ row_sidecar_sizes: OnceLock<Mutex<LruCache<String, u64>>>,
}
impl std::fmt::Debug for FileFormatMetadataCacheContext {
@@ -147,6 +156,9 @@ impl FileFormatMetadataCacheContext {
Ok(Arc::new(Self {
max_bytes,
cache: OnceLock::new(),
+ row_metadata_cache: OnceLock::new(),
+ process_id: std::process::id(),
+ row_sidecar_sizes: OnceLock::new(),
}))
}
@@ -154,19 +166,100 @@ impl FileFormatMetadataCacheContext {
self.max_bytes
}
+ fn row_metadata_max_bytes(&self) -> usize {
+ (self.max_bytes /
ROW_METADATA_CACHE_BUDGET_DIVISOR).min(ROW_METADATA_CACHE_MAX_BYTES)
+ }
+
+ fn parquet_metadata_max_bytes(&self) -> usize {
+ self.max_bytes - self.row_metadata_max_bytes()
+ }
+
+ fn row_sidecar_size(&self, key: &str) -> Option<u64> {
+ if self.max_bytes == 0 || self.process_id != std::process::id() {
+ return None;
+ }
+ self.row_sidecar_sizes
+ .get()?
+ .lock()
+ .unwrap()
+ .get(key)
+ .copied()
+ }
+
+ fn cache_row_sidecar_size(&self, key: String, size: u64) {
+ if self.max_bytes == 0 || self.process_id != std::process::id() {
+ return;
+ }
+ self.row_sidecar_sizes
+ .get_or_init(|| {
+ Mutex::new(LruCache::new(
+
NonZeroUsize::new(ROW_SIDECAR_SIZE_CACHE_MAX_ENTRIES).unwrap(),
+ ))
+ })
+ .lock()
+ .unwrap()
+ .put(key, size);
+ }
+
+ fn invalidate_row_sidecar_size(&self, key: &str) {
+ if self.process_id == std::process::id() {
+ if let Some(sizes) = self.row_sidecar_sizes.get() {
+ sizes.lock().unwrap().pop(key);
+ }
+ }
+ }
+
+ fn invalidate_row_sidecar_prefix(&self, prefix: &str) {
+ if self.process_id != std::process::id() {
+ return;
+ }
+ let Some(sizes) = self.row_sidecar_sizes.get() else {
+ return;
+ };
+ let mut sizes = sizes.lock().unwrap();
+ let keys: Vec<_> = sizes
+ .iter()
+ .filter(|(key, _)| key.starts_with(prefix))
+ .map(|(key, _)| key.clone())
+ .collect();
+ for key in keys {
+ sizes.pop(&key);
+ }
+ }
+
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>),
- );
+ let cache = Arc::clone(self.cache.get_or_init(|| {
+ Arc::new(init(self.parquet_metadata_max_bytes())) as Arc<dyn Any +
Send + Sync>
+ }));
match cache.downcast::<T>() {
Ok(cache) => cache,
Err(_) => panic!("file-format metadata cache type mismatch"),
}
}
+
+ /// 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>(
+ &self,
+ init: impl FnOnce(usize) -> T,
+ ) -> Option<Arc<T>>
+ where
+ T: Send + Sync + 'static,
+ {
+ if self.max_bytes == 0 || self.process_id != std::process::id() {
+ return None;
+ }
+ let cache = Arc::clone(self.row_metadata_cache.get_or_init(|| {
+ Arc::new(init(self.row_metadata_max_bytes())) as Arc<dyn Any +
Send + Sync>
+ }));
+ match cache.downcast::<T>() {
+ Ok(cache) => Some(cache),
+ Err(_) => panic!("ROW metadata cache type mismatch"),
+ }
+ }
}
pub(crate) struct BlobIndexCacheContext {
@@ -509,6 +602,33 @@ impl FileIO {
})
}
+ /// Return the size of an immutable ROW sidecar without repeating remote
+ /// status requests for subsequent reads through the same cache context.
+ pub(crate) async fn immutable_row_sidecar_size(&self, path: &str) ->
Result<u64> {
+ let (op, relative_path, cache_key) =
self.create_with_cache_path(path).await?;
+ if let Some(size) = cache_key
+ .as_deref()
+ .and_then(|key|
self.file_format_metadata_cache.row_sidecar_size(key))
+ {
+ return Ok(size);
+ }
+ let size = op
+ .stat(relative_path.as_ref())
+ .await
+ .map_err(|error| {
+ Error::from_opendal_with_context(
+ error,
+ format!("Failed to get file status for '{path}'"),
+ )
+ })?
+ .content_length();
+ if let Some(key) = cache_key {
+ self.file_format_metadata_cache
+ .cache_row_sidecar_size(key, size);
+ }
+ Ok(size)
+ }
+
/// List the statuses of the files/directories in the given path if the
path is a directory.
///
/// References:
<https://github.com/apache/paimon/blob/release-0.8.2/paimon-common/src/main/java/org/apache/paimon/fs/FileIO.java#L105>
@@ -664,6 +784,10 @@ impl FileIO {
op.delete(relative_path.as_ref()).await.map_err(|error| {
Error::from_opendal_with_context(error, format!("Failed to delete
file '{path}'"))
})?;
+ if let Some(ref cache_path) = cache_path {
+ self.file_format_metadata_cache
+ .invalidate_row_sidecar_size(cache_path);
+ }
if let (Some(cache), Some(cache_path)) = (
self.cache.as_ref().filter(|cache| cache.is_cacheable(path)),
cache_path,
@@ -693,6 +817,10 @@ impl FileIO {
format!("Failed to delete directory '{path}'"),
)
})?;
+ if let Some(ref cache_path) = cache_path {
+ self.file_format_metadata_cache
+ .invalidate_row_sidecar_prefix(cache_path);
+ }
if let (Some(cache), Some(cache_path)) = (&self.cache, cache_path) {
cache.invalidate_prefix(&cache_path).await?;
}
@@ -1611,6 +1739,7 @@ mod file_action_test {
let service: Servicer = Arc::new(CountingListService {
pulls: Arc::clone(&self.pulls),
fork_error: None,
+ stat_response: None,
});
Ok((
Operator::from_parts(OperationContext::default(), service),
@@ -1623,6 +1752,7 @@ mod file_action_test {
struct CountingListService {
pulls: Arc<AtomicUsize>,
fork_error: Option<&'static str>,
+ stat_response: Option<(Arc<AtomicUsize>, u64)>,
}
impl Service for CountingListService {
@@ -1663,6 +1793,12 @@ mod file_action_test {
if let Some(message) = self.fork_error {
return Err(fork_test_error(message));
}
+ if let Some((calls, size)) = &self.stat_response {
+ calls.fetch_add(1, AtomicOrdering::SeqCst);
+ return Ok(RpStat::new(
+ Metadata::new(EntryMode::FILE).with_content_length(*size),
+ ));
+ }
Err(unsupported_test_operation())
}
@@ -1919,6 +2055,7 @@ mod file_action_test {
let service: Servicer = Arc::new(CountingListService {
pulls: Arc::new(AtomicUsize::new(0)),
fork_error: Some(message),
+ stat_response: None,
});
let operator = Operator::from_parts(OperationContext::default(),
service);
let file_io =
setup_memory_file_io().with_provider(Arc::new(FixedProvider(operator)));
@@ -2184,6 +2321,72 @@ mod file_action_test {
assert!(dir.path().join("t1").exists());
assert!(!dir.path().join("t1 ").exists());
}
+
+ #[tokio::test]
+ async fn test_immutable_row_sidecar_size_cache_avoids_repeated_stat() {
+ #[derive(Debug)]
+ struct CountingStatProvider(Operator);
+
+ #[async_trait::async_trait]
+ impl FileIOProvider for CountingStatProvider {
+ async fn create(&self, path: &str) -> crate::Result<(Operator,
String)> {
+ Ok((
+ self.0.clone(),
+ path.strip_prefix("counting:/").unwrap().to_string(),
+ ))
+ }
+
+ async fn create_with_cache_namespace(
+ &self,
+ path: &str,
+ ) -> crate::Result<(Operator, String, Option<String>)> {
+ let (op, relative_path) = self.create(path).await?;
+ Ok((op, relative_path, Some("counting-stat".to_string())))
+ }
+ }
+
+ let calls = Arc::new(AtomicUsize::new(0));
+ let service: Servicer = Arc::new(CountingListService {
+ pulls: Arc::new(AtomicUsize::new(0)),
+ fork_error: None,
+ stat_response: Some((Arc::clone(&calls), 123)),
+ });
+ let provider = Arc::new(CountingStatProvider(Operator::from_parts(
+ OperationContext::default(),
+ service,
+ )));
+ let path = "counting:/objects/sidecar.row";
+
+ let cached = FileIOBuilder::new("memory")
+ .with_provider(provider.clone())
+ .build()
+ .unwrap();
+ assert_eq!(cached.immutable_row_sidecar_size(path).await.unwrap(),
123);
+ assert_eq!(
+ cached
+ .clone()
+ .immutable_row_sidecar_size(path)
+ .await
+ .unwrap(),
+ 123
+ );
+ assert_eq!(calls.load(AtomicOrdering::SeqCst), 1);
+
+ let uncached = FileIOBuilder::new("memory")
+ .with_provider(provider)
+ .with_prop(CatalogOptions::FILE_FORMAT_METADATA_CACHE_MAX_SIZE,
"0")
+ .build()
+ .unwrap();
+ assert_eq!(
+ uncached.immutable_row_sidecar_size(path).await.unwrap(),
+ 123
+ );
+ assert_eq!(
+ uncached.immutable_row_sidecar_size(path).await.unwrap(),
+ 123
+ );
+ assert_eq!(calls.load(AtomicOrdering::SeqCst), 3);
+ }
}
#[cfg(all(
@@ -2687,6 +2890,113 @@ mod input_output_test {
assert!(!std::ptr::eq(first_cache, second_cache));
}
+ #[test]
+ fn
row_metadata_cache_has_separate_type_and_bypasses_inherited_fork_state() {
+ let mut context =
FileFormatMetadataCacheContext::from_props(&HashMap::new()).unwrap();
+ assert_eq!(*context.get_or_init(|_| 7u8), 7);
+ assert_eq!(*context.row_metadata_get_or_init(|_| 11u16).unwrap(), 11);
+ Arc::get_mut(&mut context).unwrap().process_id = 0;
+ assert!(context.row_metadata_get_or_init(|_| 13u16).is_none());
+ }
+
+ #[test]
+ fn row_and_parquet_metadata_share_configured_budget() {
+ let context =
FileFormatMetadataCacheContext::from_props(&HashMap::from([(
+ CatalogOptions::FILE_FORMAT_METADATA_CACHE_MAX_SIZE.to_string(),
+ "64 kb".to_string(),
+ )]))
+ .unwrap();
+ let parquet_budget = *context.get_or_init(|bytes| bytes);
+ let row_budget = *context.row_metadata_get_or_init(|bytes|
bytes).unwrap();
+ assert_eq!(parquet_budget, 48 * 1024);
+ assert_eq!(row_budget, 16 * 1024);
+ assert_eq!(parquet_budget + row_budget, context.max_bytes());
+
+ let default =
FileFormatMetadataCacheContext::from_props(&HashMap::new()).unwrap();
+ assert_eq!(
+ default.parquet_metadata_max_bytes() +
default.row_metadata_max_bytes(),
+ DEFAULT_FILE_FORMAT_METADATA_CACHE_MAX_BYTES
+ );
+ }
+
+ #[tokio::test]
+ async fn
test_immutable_row_sidecar_size_cache_is_scoped_and_disabled_with_metadata_cache()
{
+ let path = "memory:/row-sidecar-size-cache.row";
+ let first = setup_memory_file_io();
+ assert!(first
+ .file_format_metadata_cache
+ .row_sidecar_sizes
+ .get()
+ .is_none());
+ first
+ .new_output(path)
+ .unwrap()
+ .write(Bytes::from_static(b"first"))
+ .await
+ .unwrap();
+ assert!(first
+ .file_format_metadata_cache
+ .row_sidecar_sizes
+ .get()
+ .is_none());
+ assert_eq!(first.immutable_row_sidecar_size(path).await.unwrap(), 5);
+ assert!(first
+ .file_format_metadata_cache
+ .row_sidecar_sizes
+ .get()
+ .is_some());
+
+ // The same context reuses the size. A different FileIO with the same
+ // literal path has its own storage target and must not reuse it.
+ assert_eq!(
+ first
+ .clone()
+ .immutable_row_sidecar_size(path)
+ .await
+ .unwrap(),
+ 5
+ );
+ assert!(setup_memory_file_io()
+ .immutable_row_sidecar_size(path)
+ .await
+ .is_err());
+ first.delete_file(path).await.unwrap();
+ assert!(first.immutable_row_sidecar_size(path).await.is_err());
+
+ let nested = "memory:/row-sidecar-size-prefix/sidecar.row";
+ first
+ .new_output(nested)
+ .unwrap()
+ .write(Bytes::from_static(b"nested"))
+ .await
+ .unwrap();
+ assert_eq!(first.immutable_row_sidecar_size(nested).await.unwrap(), 6);
+ first
+ .delete_dir("memory:/row-sidecar-size-prefix")
+ .await
+ .unwrap();
+ assert!(first.immutable_row_sidecar_size(nested).await.is_err());
+
+ let uncached = FileIOBuilder::new("memory")
+ .with_prop(CatalogOptions::FILE_FORMAT_METADATA_CACHE_MAX_SIZE,
"0")
+ .build()
+ .unwrap();
+ uncached
+ .new_output(path)
+ .unwrap()
+ .write(Bytes::from_static(b"data"))
+ .await
+ .unwrap();
+ assert_eq!(uncached.immutable_row_sidecar_size(path).await.unwrap(),
4);
+ uncached.delete_file(path).await.unwrap();
+ assert!(uncached.immutable_row_sidecar_size(path).await.is_err());
+ assert!(uncached
+ .file_format_metadata_cache
+ .row_sidecar_sizes
+ .get()
+ .is_none());
+ }
+
#[tokio::test]
async fn test_file_format_metadata_cache_size_reaches_file_reader() {
let path = "memory:/metadata-cache-size.parquet";
diff --git a/crates/paimon/src/table/row_sidecar.rs
b/crates/paimon/src/table/row_sidecar.rs
index 624ca6ef..a337b9c9 100644
--- a/crates/paimon/src/table/row_sidecar.rs
+++ b/crates/paimon/src/table/row_sidecar.rs
@@ -242,8 +242,8 @@ pub(super) async fn read_target(
) -> Result<(String, u64)> {
if let Some(name) = selected_sidecar(file, local_ranges, options)? {
let path = file.aligned_file_path(bucket_path, name);
- let status = io.get_status(&path).await?;
- return Ok((path, status.size));
+ let size = io.immutable_row_sidecar_size(&path).await?;
+ return Ok((path, size));
}
Ok((file.data_file_path(bucket_path), file.file_size as u64))
}