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 833edacc [rust] Cache decoded BLOB indexes across readers (#897)
833edacc is described below
commit 833edacce90536337a68a95a796167b85890ca8f
Author: XiaoHongbo <[email protected]>
AuthorDate: Mon Sep 28 19:43:55 2026 +0800
[rust] Cache decoded BLOB indexes across readers (#897)
---
crates/paimon/src/arrow/format/blob.rs | 177 ++++++++++++++++++++++++++--
crates/paimon/src/io/file_io.rs | 58 +++++++--
crates/paimon/src/table/data_file_reader.rs | 19 +++
3 files changed, 234 insertions(+), 20 deletions(-)
diff --git a/crates/paimon/src/arrow/format/blob.rs
b/crates/paimon/src/arrow/format/blob.rs
index e15ef57b..69d93efb 100644
--- a/crates/paimon/src/arrow/format/blob.rs
+++ b/crates/paimon/src/arrow/format/blob.rs
@@ -33,6 +33,7 @@ use async_stream::try_stream;
use async_trait::async_trait;
use bytes::Bytes;
use futures::{StreamExt, TryStreamExt};
+use lru::LruCache;
use std::ops::Range;
use std::sync::Arc;
@@ -60,7 +61,7 @@ impl BlobFormatReader {
pub(crate) struct IndexedBlobReader {
reader: Box<dyn FileRead>,
- index: BlobFileIndex,
+ index: Arc<BlobFileIndex>,
descriptor_mode: bool,
file_path: String,
blob_parallelism: usize,
@@ -92,7 +93,7 @@ impl IndexedBlobReader {
blob_parallelism: usize,
) -> crate::Result<Self> {
debug_assert!(blob_parallelism > 0);
- let index = BlobFileIndex::load(reader.as_ref(), file_size).await?;
+ let index = BlobFileIndex::load_cached(reader.as_ref(), file_size,
&file_path).await?;
Ok(Self {
reader,
index,
@@ -162,6 +163,20 @@ pub(crate) enum BlobReadValue {
const BLOB_FOOTER_SIZE: u64 = 5;
const BLOB_FORMAT_VERSION: u8 = 1;
+const BLOB_INDEX_CACHE_CAPACITY: usize = 16;
+#[derive(Clone, Debug, Eq, Hash, PartialEq)]
+struct BlobIndexCacheKey {
+ namespace: usize,
+ file_path: String,
+}
+
+static BLOB_INDEX_CACHE: std::sync::LazyLock<
+ std::sync::Mutex<LruCache<BlobIndexCacheKey, Arc<BlobFileIndex>>>,
+> = std::sync::LazyLock::new(|| {
+ std::sync::Mutex::new(LruCache::new(
+ std::num::NonZeroUsize::new(BLOB_INDEX_CACHE_CAPACITY).unwrap(),
+ ))
+});
const BLOB_MAGIC_NUMBER: i32 = 1481511375;
const BLOB_MAGIC_NUMBER_BYTES: [u8; 4] = BLOB_MAGIC_NUMBER.to_le_bytes();
const BLOB_INLINE_HEADER_SIZE: u64 = 4;
@@ -1653,12 +1668,43 @@ struct BlobArrayLayout {
element_index_range: Range<u64>,
}
-#[derive(Debug, Clone)]
+#[derive(Debug)]
struct BlobFileIndex {
entries: Vec<BlobEntry>,
}
impl BlobFileIndex {
+ async fn load_cached(
+ reader: &dyn FileRead,
+ file_size: u64,
+ file_path: &str,
+ ) -> crate::Result<Arc<Self>> {
+ let cache_key = reader
+ .cache_namespace()
+ .filter(|_| !file_path.is_empty())
+ .map(|namespace| BlobIndexCacheKey {
+ namespace,
+ file_path: file_path.to_string(),
+ });
+ if let Some(cache_key) = &cache_key {
+ let mut cache = BLOB_INDEX_CACHE
+ .lock()
+ .unwrap_or_else(|error| error.into_inner());
+ if let Some(index) = cache.get(cache_key) {
+ return Ok(index.clone());
+ }
+ }
+
+ let index = Arc::new(Self::load(reader, file_size).await?);
+ if let Some(cache_key) = cache_key {
+ BLOB_INDEX_CACHE
+ .lock()
+ .unwrap_or_else(|error| error.into_inner())
+ .put(cache_key, index.clone());
+ }
+ Ok(index)
+ }
+
async fn load(reader: &dyn FileRead, file_size: u64) ->
crate::Result<Self> {
if file_size < BLOB_FOOTER_SIZE {
return Err(Error::DataInvalid {
@@ -2282,6 +2328,7 @@ fn encode_varint(value: i64, out: &mut Vec<u8>) {
mod tests {
use super::*;
use crate::btree::test_util::BytesFileRead;
+ use crate::io::{FileIO, FileIOBuilder};
use crate::spec::{ArrayType, BlobType, MapType, VarCharType};
use arrow_array::Array;
use bytes::Bytes;
@@ -2351,6 +2398,86 @@ mod tests {
);
}
+ #[tokio::test]
+ async fn test_blob_reader_reuses_cached_index() {
+ let file_path = "file:///blob-index-cache-test/data.blob";
+ let file_bytes = load_blob_fixture("blob-basic.blob");
+ let first =
+
TrackingFileRead::new(Bytes::from(file_bytes.clone())).with_cache_namespace(usize::MAX);
+ let second =
+
TrackingFileRead::new(Bytes::from(file_bytes.clone())).with_cache_namespace(usize::MAX);
+
+ let first_reader = IndexedBlobReader::open(
+ Box::new(first.clone()),
+ file_bytes.len() as u64,
+ file_path.to_string(),
+ true,
+ )
+ .await
+ .unwrap();
+ let second_reader = IndexedBlobReader::open(
+ Box::new(second.clone()),
+ file_bytes.len() as u64,
+ file_path.to_string(),
+ true,
+ )
+ .await
+ .unwrap();
+
+ assert_eq!(first_reader.num_rows(), second_reader.num_rows());
+ assert_eq!(first.ranges().len(), 2);
+ assert!(second.ranges().is_empty());
+ }
+
+ #[tokio::test]
+ async fn test_blob_index_cache_isolated_by_file_io() {
+ let path = "memory:///blob-index-cache-namespace/data.blob";
+ let value = b"value";
+ let first_bytes = blob_test_utils::build_blob_file_bytes(&[None,
Some(value.as_slice())]);
+ let second_bytes =
blob_test_utils::build_blob_file_bytes(&[Some(value.as_slice()), None]);
+ assert_eq!(first_bytes.len(), second_bytes.len());
+
+ let first_io = FileIOBuilder::new("memory").build().unwrap();
+ let second_io = FileIOBuilder::new("memory").build().unwrap();
+ first_io
+ .new_output(path)
+ .unwrap()
+ .write(Bytes::from(first_bytes))
+ .await
+ .unwrap();
+ second_io
+ .new_output(path)
+ .unwrap()
+ .write(Bytes::from(second_bytes))
+ .await
+ .unwrap();
+
+ let first_namespace = first_io
+ .new_input(path)
+ .unwrap()
+ .reader()
+ .await
+ .unwrap()
+ .cache_namespace();
+ let second_namespace = second_io
+ .new_input(path)
+ .unwrap()
+ .reader()
+ .await
+ .unwrap()
+ .cache_namespace();
+ assert_ne!(first_namespace, second_namespace);
+
+ assert_eq!(
+ read_scalar_blob_file(&first_io, path).await,
+ vec![None, Some(value.to_vec())]
+ );
+ assert_eq!(
+ read_scalar_blob_file(&second_io, path).await,
+ vec![Some(value.to_vec()), None]
+ );
+ }
+
#[tokio::test]
async fn test_blob_array_reader_reads_java_fixture() {
let read_fields = vec![DataField::new(
@@ -2420,6 +2547,7 @@ mod tests {
#[tokio::test]
async fn test_blob_map_reader_returns_inline_values_and_descriptors() {
+ let file_path = "file:///tmp/map-values-and-descriptors.blob";
let payload = build_blob_map_payload(&[
("video", Some(b"alpha")),
("thumbnail", None),
@@ -2428,7 +2556,7 @@ mod tests {
let file_bytes =
blob_test_utils::build_blob_file_bytes(&[Some(payload.as_slice()), None]);
let fields = blob_map_read_fields();
- let inline = BlobFormatReader::new("file:///tmp/map.blob".to_string(),
false)
+ let inline = BlobFormatReader::new(file_path.to_string(), false)
.read_batch_stream(
Box::new(BytesFileRead(Bytes::from(file_bytes.clone()))),
file_bytes.len() as u64,
@@ -2454,7 +2582,7 @@ mod tests {
]
);
- let descriptors =
BlobFormatReader::new("file:///tmp/map.blob".to_string(), true)
+ let descriptors = BlobFormatReader::new(file_path.to_string(), true)
.read_batch_stream(
Box::new(BytesFileRead(Bytes::from(file_bytes.clone()))),
file_bytes.len() as u64,
@@ -2471,7 +2599,7 @@ mod tests {
let rows = collect_blob_map_values(&descriptors[0]);
let entries = rows[0].as_ref().unwrap();
let video =
BlobDescriptor::deserialize(entries[0].1.as_ref().unwrap()).unwrap();
- assert_eq!(video.uri(), "file:///tmp/map.blob");
+ assert_eq!(video.uri(), file_path);
assert_eq!(video.length(), 5);
assert!(entries[1].1.is_none());
let empty =
BlobDescriptor::deserialize(entries[2].1.as_ref().unwrap()).unwrap();
@@ -2521,7 +2649,7 @@ mod tests {
#[tokio::test]
async fn test_blob_map_descriptor_read_skips_values() {
- let file_path = "file:///tmp/map.blob";
+ let file_path = "file:///tmp/map-descriptor-skip-values.blob";
let payload =
build_blob_map_payload(&[("first", Some(b"alpha")), ("second",
Some(b"beta"))]);
let file_bytes =
blob_test_utils::build_blob_file_bytes(&[Some(payload.as_slice())]);
@@ -3598,6 +3726,30 @@ mod tests {
Ok(batches.iter().flat_map(collect_binary_values).collect())
}
+ async fn read_scalar_blob_file(file_io: &FileIO, path: &str) ->
Vec<Option<Vec<u8>>> {
+ let input = file_io.new_input(path).unwrap();
+ let file_size = input.metadata().await.unwrap().size;
+ let batches = BlobFormatReader::new(path.to_string(), false)
+ .read_batch_stream(
+ Box::new(input.reader().await.unwrap()),
+ file_size,
+ &[DataField::new(
+ 0,
+ "payload".to_string(),
+ DataType::Blob(BlobType::new()),
+ )],
+ None,
+ None,
+ None,
+ )
+ .await
+ .unwrap()
+ .try_collect::<Vec<_>>()
+ .await
+ .unwrap();
+ batches.iter().flat_map(collect_binary_values).collect()
+ }
+
fn rewrite_first_blob_entry_crc(file_bytes: &mut [u8], payload_length:
usize) {
let crc_offset = BLOB_INLINE_HEADER_SIZE as usize + payload_length +
size_of::<i64>();
let mut hasher = crc32fast::Hasher::new();
@@ -3690,6 +3842,7 @@ mod tests {
#[derive(Clone)]
struct TrackingFileRead {
bytes: Bytes,
+ cache_namespace: Option<usize>,
in_flight: Arc<AtomicUsize>,
max_in_flight: Arc<AtomicUsize>,
ranges: Arc<Mutex<Vec<Range<u64>>>>,
@@ -3699,12 +3852,18 @@ mod tests {
fn new(bytes: Bytes) -> Self {
Self {
bytes,
+ cache_namespace: None,
in_flight: Arc::new(AtomicUsize::new(0)),
max_in_flight: Arc::new(AtomicUsize::new(0)),
ranges: Arc::new(Mutex::new(Vec::new())),
}
}
+ fn with_cache_namespace(mut self, cache_namespace: usize) -> Self {
+ self.cache_namespace = Some(cache_namespace);
+ self
+ }
+
fn max_in_flight(&self) -> usize {
self.max_in_flight.load(Ordering::SeqCst)
}
@@ -3724,6 +3883,10 @@ mod tests {
self.in_flight.fetch_sub(1, Ordering::SeqCst);
Ok(self.bytes.slice(range.start as usize..range.end as usize))
}
+
+ fn cache_namespace(&self) -> Option<usize> {
+ self.cache_namespace
+ }
}
struct SparseFileRead {
diff --git a/crates/paimon/src/io/file_io.rs b/crates/paimon/src/io/file_io.rs
index bc8e2fb5..8d4b966a 100644
--- a/crates/paimon/src/io/file_io.rs
+++ b/crates/paimon/src/io/file_io.rs
@@ -20,6 +20,7 @@ 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::task::{Context, Poll};
use std::time::SystemTime;
@@ -90,6 +91,13 @@ enum FileIOBackend {
pub struct FileIO {
backend: FileIOBackend,
cache: Option<Arc<LocalCache>>,
+ cache_namespace: usize,
+}
+
+static NEXT_FILE_IO_CACHE_NAMESPACE: AtomicUsize = AtomicUsize::new(1);
+
+fn next_file_io_cache_namespace() -> usize {
+ NEXT_FILE_IO_CACHE_NAMESPACE.fetch_add(1, Ordering::Relaxed)
}
impl std::fmt::Debug for FileIO {
@@ -126,6 +134,7 @@ impl FileIO {
/// subsequently created by [`Self::new_input`] and [`Self::new_output`].
pub fn with_provider(mut self, provider: Arc<dyn FileIOProvider>) -> Self {
self.backend = FileIOBackend::Provider(provider);
+ self.cache_namespace = next_file_io_cache_namespace();
self
}
@@ -211,6 +220,7 @@ impl FileIO {
Ok(InputFile {
source: self.file_source(path)?,
path: path.to_string(),
+ cache_namespace: self.cache_namespace,
cache: self
.cache
.as_ref()
@@ -227,6 +237,7 @@ impl FileIO {
Ok(OutputFile {
source: self.file_source(path)?,
path: path.to_string(),
+ cache_namespace: self.cache_namespace,
cache: self
.cache
.as_ref()
@@ -682,13 +693,22 @@ impl FileIOBuilder {
} else {
FileIOBackend::Storage(Arc::new(Storage::build(self)?))
};
- Ok(FileIO { backend, cache })
+ Ok(FileIO {
+ backend,
+ cache,
+ cache_namespace: next_file_io_cache_namespace(),
+ })
}
}
#[async_trait::async_trait]
pub trait FileRead: Send + Sync + Unpin + 'static {
async fn read(&self, range: Range<u64>) -> crate::Result<Bytes>;
+
+ #[doc(hidden)]
+ fn cache_namespace(&self) -> Option<usize> {
+ None
+ }
}
#[async_trait::async_trait]
@@ -699,18 +719,24 @@ impl FileRead for opendal::Reader {
}
enum InputFileReader {
- Direct(opendal::Reader),
- Cached(CachedFileReader),
+ Direct(opendal::Reader, usize),
+ Cached(CachedFileReader, usize),
}
#[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_namespace(&self) -> Option<usize> {
+ Some(match self {
+ Self::Direct(_, namespace) | Self::Cached(_, namespace) =>
*namespace,
+ })
+ }
}
#[async_trait::async_trait]
@@ -847,6 +873,7 @@ impl FileSource {
pub struct InputFile {
source: FileSource,
path: String,
+ cache_namespace: usize,
cache: Option<Arc<LocalCache>>,
}
@@ -897,7 +924,7 @@ 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));
+ return Ok(InputFileReader::Direct(reader, self.cache_namespace));
};
let read_token = cache.read_token(&cache_path);
let size = if let Some(size) = cache.file_size(&cache_path,
&read_token).await {
@@ -907,13 +934,16 @@ impl InputFile {
cache.put_file_size(&cache_path, size, &read_token).await;
size
};
- Ok(InputFileReader::Cached(CachedFileReader::new_with_token(
- Arc::new(reader),
- &cache_path,
- size,
- cache.clone(),
- read_token,
- )))
+ Ok(InputFileReader::Cached(
+ CachedFileReader::new_with_token(
+ Arc::new(reader),
+ &cache_path,
+ size,
+ cache.clone(),
+ read_token,
+ ),
+ self.cache_namespace,
+ ))
}
}
@@ -921,6 +951,7 @@ impl InputFile {
pub struct OutputFile {
source: FileSource,
path: String,
+ cache_namespace: usize,
cache: Option<Arc<LocalCache>>,
}
@@ -939,6 +970,7 @@ impl OutputFile {
InputFile {
source: self.source,
path: self.path,
+ cache_namespace: self.cache_namespace,
cache,
}
}
diff --git a/crates/paimon/src/table/data_file_reader.rs
b/crates/paimon/src/table/data_file_reader.rs
index 16d6bfa1..009baf49 100644
--- a/crates/paimon/src/table/data_file_reader.rs
+++ b/crates/paimon/src/table/data_file_reader.rs
@@ -107,6 +107,10 @@ impl FileRead for TimedFileRead {
self.timing.add_file_read(start.elapsed());
result
}
+
+ fn cache_namespace(&self) -> Option<usize> {
+ self.inner.cache_namespace()
+ }
}
/// Reads data files through their format-specific readers.
@@ -2070,6 +2074,21 @@ mod tests {
);
}
+ #[tokio::test]
+ async fn timed_file_read_preserves_cache_namespace() {
+ let file_io = FileIOBuilder::new("memory").build().unwrap();
+ let input = file_io.new_input("memory:/timed-file").unwrap();
+ let reader = input.reader().await.unwrap();
+ let cache_namespace = reader.cache_namespace();
+ let timed = TimedFileRead {
+ inner: Box::new(reader),
+ timing: Arc::new(DataFileReadTiming::default()),
+ };
+
+ assert!(cache_namespace.is_some());
+ assert_eq!(timed.cache_namespace(), cache_namespace);
+ }
+
#[test]
fn merge_row_selection_skips_only_unfiltered_full_coverage() {
let full = [RowRange::new(0, 9)];