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 b2642a59 [io] Reduce BLOB footer and index reads with tail prefetch
(#1010)
b2642a59 is described below
commit b2642a59189aeee739224b621b5ae4e736c5a943
Author: XiaoHongbo <[email protected]>
AuthorDate: Fri Oct 2 18:39:09 2026 +0800
[io] Reduce BLOB footer and index reads with tail prefetch (#1010)
---
crates/paimon/src/arrow/format/blob.rs | 239 ++++++++++++++++++++++++++++++---
1 file changed, 217 insertions(+), 22 deletions(-)
diff --git a/crates/paimon/src/arrow/format/blob.rs
b/crates/paimon/src/arrow/format/blob.rs
index 2280b9c1..6b992a66 100644
--- a/crates/paimon/src/arrow/format/blob.rs
+++ b/crates/paimon/src/arrow/format/blob.rs
@@ -1673,6 +1673,21 @@ struct BlobFileIndex {
entries: Vec<BlobEntry>,
}
+async fn read_blob_range(reader: &dyn FileRead, range: Range<u64>) ->
crate::Result<Bytes> {
+ let expected = range.end - range.start;
+ let bytes = reader.read(range.clone()).await?;
+ if bytes.len() as u64 != expected {
+ return Err(Error::DataInvalid {
+ message: format!(
+ "Short BLOB index read for {range:?}: expected {expected}
bytes, got {}",
+ bytes.len()
+ ),
+ source: None,
+ });
+ }
+ Ok(bytes)
+}
+
impl BlobFileIndex {
async fn load_cached(
reader: &dyn FileRead,
@@ -1715,19 +1730,11 @@ impl BlobFileIndex {
});
}
- let footer = reader
- .read(file_size - BLOB_FOOTER_SIZE..file_size)
- .await
- .map_err(|e| Error::UnexpectedError {
- message: format!("Failed to read blob footer: {e}"),
- source: Some(Box::new(e)),
- })?;
-
- let footer_bytes: [u8; BLOB_FOOTER_SIZE as usize] =
- footer.as_ref().try_into().map_err(|_| Error::DataInvalid {
- message: "Blob footer should be exactly 5 bytes".to_string(),
- source: None,
- })?;
+ const TAIL_PREFETCH_SIZE: u64 = 4096;
+ let tail_start = file_size - file_size.min(TAIL_PREFETCH_SIZE);
+ let tail = read_blob_range(reader, tail_start..file_size).await?;
+ let footer_start = tail.len() - BLOB_FOOTER_SIZE as usize;
+ let footer_bytes = &tail[footer_start..];
let index_length =
i32::from_le_bytes(footer_bytes[..4].try_into().unwrap());
if index_length < 0 {
return Err(Error::DataInvalid {
@@ -1757,13 +1764,12 @@ impl BlobFileIndex {
let index_start = file_size - BLOB_FOOTER_SIZE - index_length;
let data_region_end = index_start;
- let index_bytes = reader
- .read(index_start..index_start + index_length)
- .await
- .map_err(|e| Error::UnexpectedError {
- message: format!("Failed to read blob index bytes: {e}"),
- source: Some(Box::new(e)),
- })?;
+ let index_bytes = if index_start >= tail_start {
+ tail.slice((index_start - tail_start) as usize..footer_start)
+ } else {
+ // Reread the small tail overlap rather than copy the entire index.
+ read_blob_range(reader, index_start..file_size -
BLOB_FOOTER_SIZE).await?
+ };
let lengths = decode_delta_varints(index_bytes.as_ref())?;
let entries = BlobEntry::build_all(&lengths, data_region_end)?;
@@ -2342,6 +2348,190 @@ mod tests {
include!(concat!(env!("CARGO_MANIFEST_DIR"), "/blob_test_utils.rs"));
}
+ #[tokio::test]
+ async fn test_index_tail_prefetch_boundaries() {
+ for rows in [0, 1, 4090, 4091, 4092, 9000] {
+ let mut data = vec![0; 8192];
+ let index = encode_delta_varints_write(&vec![-1; rows]);
+ assert_eq!(index.len(), rows);
+ data.extend_from_slice(&index);
+ data.extend_from_slice(&(index.len() as i32).to_le_bytes());
+ data.push(BLOB_FORMAT_VERSION);
+ let size = data.len() as u64;
+ let reader = TrackingFileRead::new(Bytes::from(data));
+ let result = BlobFileIndex::load(&reader, size).await.unwrap();
+ assert_eq!(result.num_rows(), rows);
+ assert!(result
+ .entries
+ .iter()
+ .all(|entry| matches!(entry, BlobEntry::Null)));
+ let mut expected = Vec::with_capacity(2);
+ expected.push(size - 4096..size);
+ if rows + 5 > 4096 {
+ expected.push(8192..size - BLOB_FOOTER_SIZE);
+ }
+ assert_eq!(reader.ranges(), expected);
+ }
+ }
+
+ #[tokio::test]
+ async fn test_large_index_varint_crosses_tail_boundary() {
+ let lengths = vec![128; 4091];
+ let index = encode_delta_varints_write(&lengths);
+ assert_eq!(index.len(), 4092);
+ // The tail starts between the two bytes encoding the first length.
+ assert_ne!(index[0] & 0x80, 0);
+ assert_eq!(index[1] & 0x80, 0);
+ let index_start = 128 * lengths.len() as u64;
+ let expected = BlobEntry::build_all(&lengths, index_start).unwrap();
+ let mut data = vec![0; index_start as usize];
+ data.extend_from_slice(&index);
+ data.extend_from_slice(&(index.len() as i32).to_le_bytes());
+ data.push(BLOB_FORMAT_VERSION);
+ let size = data.len() as u64;
+ assert_eq!(size - 4096, index_start + 1);
+ let reader = TrackingFileRead::new(Bytes::from(data));
+ let actual = BlobFileIndex::load(&reader, size).await.unwrap();
+ assert_eq!(format!("{:?}", actual.entries), format!("{expected:?}"));
+ assert_eq!(
+ reader.ranges(),
+ vec![size - 4096..size, index_start..size - BLOB_FOOTER_SIZE]
+ );
+ }
+
+ #[tokio::test]
+ async fn test_index_tail_prefetch_matches_legacy_fixtures() {
+ for name in [
+ "blob-basic.blob",
+ "blob-array.blob",
+ "blob-placeholder.blob",
+ ] {
+ let data = load_blob_fixture(name);
+ let size = data.len() as u64;
+ let index_length =
+ i32::from_le_bytes(data[data.len() - 5..data.len() -
1].try_into().unwrap())
+ as usize;
+ let index_start = data.len() - 5 - index_length;
+ let legacy = BlobEntry::build_all(
+ &decode_delta_varints(&data[index_start..data.len() -
5]).unwrap(),
+ index_start as u64,
+ )
+ .unwrap();
+ let reader = TrackingFileRead::new(Bytes::from(data));
+ let actual = BlobFileIndex::load(&reader, size).await.unwrap();
+ assert_eq!(format!("{:?}", actual.entries), format!("{legacy:?}"));
+ let positions = (0..actual.num_rows()).rev().collect::<Vec<_>>();
+ let old = BlobFileIndex { entries: legacy };
+ assert_eq!(
+ format!(
+ "{:?}",
+ build_descriptor_values(&actual, &positions,
"file:///test.blob").unwrap()
+ ),
+ format!(
+ "{:?}",
+ build_descriptor_values(&old, &positions,
"file:///test.blob").unwrap()
+ ),
+ );
+ assert_eq!(reader.ranges(), vec![size.saturating_sub(4096)..size]);
+ }
+ }
+
+ #[tokio::test]
+ async fn test_index_tail_prefetch_invalid_input() {
+ for size in 0..5 {
+ let reader = TrackingFileRead::new(Bytes::from(vec![0; size]));
+ assert!(BlobFileIndex::load(&reader, size as u64).await.is_err());
+ assert!(reader.ranges().is_empty());
+ }
+ for (index, length, version) in [
+ (vec![], -1_i32, 1_u8),
+ (vec![], 1, 1),
+ (vec![], 0, 2),
+ (vec![0x80], 1, 1),
+ ] {
+ let mut data = index;
+ data.extend_from_slice(&length.to_le_bytes());
+ data.push(version);
+ let size = data.len() as u64;
+ let reader = TrackingFileRead::new(Bytes::from(data));
+ assert!(BlobFileIndex::load(&reader, size).await.is_err());
+ assert_eq!(reader.ranges(), vec![0..size]);
+ }
+ let reader = TrackingFileRead::new(Bytes::from_static(&[0, 0, 0, 0,
1]));
+ assert_eq!(BlobFileIndex::load(&reader, 5).await.unwrap().num_rows(),
0);
+ }
+
+ #[tokio::test]
+ async fn test_index_tail_prefetch_short_reads_and_errors() {
+ for bytes in [Bytes::new(), Bytes::from_static(&[0; 4])] {
+ let reader = SparseFileRead::new(vec![(0..5, bytes)]);
+ assert!(matches!(
+ BlobFileIndex::load(&reader, 5).await,
+ Err(Error::DataInvalid { .. })
+ ));
+ }
+ let reader = SparseFileRead::new(vec![]);
+ assert!(BlobFileIndex::load(&reader, 5).await.is_err());
+ let mut tail = vec![0; 4091];
+ tail.extend_from_slice(&5000_i32.to_le_bytes());
+ tail.push(1);
+ for index in [None, Some(Bytes::new()), Some(Bytes::from(vec![0;
4999]))] {
+ let mut responses = vec![(909..5005, Bytes::from(tail.clone()))];
+ if let Some(index) = index {
+ responses.push((0..5000, index));
+ }
+ let reader = SparseFileRead::new(responses);
+ assert!(BlobFileIndex::load(&reader, 5005).await.is_err());
+ assert_eq!(reader.ranges(), vec![909..5005, 0..5000]);
+ }
+ }
+
+ #[tokio::test]
+ async fn test_index_tail_prefetch_sixteen_cold_files() {
+ let mut requests = 0;
+ let mut bytes = 0;
+ let mut legacy_bytes = 0;
+ let mut legacy_requests = 0;
+ for rows in (1526..).take(16) {
+ let index = encode_delta_varints_write(&vec![-1; rows]);
+ let mut data = vec![0; 8192];
+ data.extend_from_slice(&index);
+ data.extend_from_slice(&(index.len() as i32).to_le_bytes());
+ data.push(1);
+ let size = data.len() as u64;
+ let reader = TrackingFileRead::new(Bytes::from(data.clone()));
+ let legacy_reader = TrackingFileRead::new(Bytes::from(data));
+ let footer = legacy_reader.read(size - 5..size).await.unwrap();
+ let length = i32::from_le_bytes(footer[..4].try_into().unwrap())
as u64;
+ let old_index = legacy_reader
+ .read(size - 5 - length..size - 5)
+ .await
+ .unwrap();
+ let old_entries = BlobEntry::build_all(
+ &decode_delta_varints(&old_index).unwrap(),
+ size - 5 - length,
+ )
+ .unwrap();
+ let loaded = BlobFileIndex::load(&reader, size).await.unwrap();
+ assert_eq!(loaded.num_rows(), rows);
+ assert_eq!(format!("{:?}", loaded.entries),
format!("{old_entries:?}"));
+ requests += reader.ranges().len();
+ bytes += reader
+ .ranges()
+ .iter()
+ .map(|range| range.end - range.start)
+ .sum::<u64>();
+ legacy_requests += legacy_reader.ranges().len();
+ legacy_bytes += legacy_reader
+ .ranges()
+ .iter()
+ .map(|r| r.end - r.start)
+ .sum::<u64>();
+ }
+ assert_eq!(legacy_requests, 32);
+ assert_eq!((requests, bytes, legacy_bytes), (16, 65536, 24616));
+ }
+
#[tokio::test]
async fn test_blob_reader_reads_inline_bytes_and_selection() {
let read_fields = vec![DataField::new(
@@ -2424,7 +2614,7 @@ mod tests {
.unwrap();
assert_eq!(first_reader.num_rows(), second_reader.num_rows());
- assert_eq!(first.ranges().len(), 2);
+ assert_eq!(first.ranges().len(), 1);
assert!(second.ranges().is_empty());
}
@@ -2641,6 +2831,7 @@ mod tests {
let payload_reads = reader
.ranges()
.into_iter()
+ .skip(1) // The bounded index prefetch may include payload bytes.
.filter(|range| range.start < payload_end && range.end > 0)
.collect::<Vec<_>>();
assert_eq!(payload_reads, vec![0..payload_end]);
@@ -2675,6 +2866,7 @@ mod tests {
assert!(reader
.ranges()
.iter()
+ .skip(1) // Exclude the bounded index prefetch.
.all(|range| range.end <= value_range.start || range.start >=
value_range.end));
}
}
@@ -2919,8 +3111,9 @@ mod tests {
!reader
.ranges()
.iter()
+ .skip(1) // Exclude the bounded index prefetch.
.any(|range| range.start < 23 && range.end > 13),
- "descriptor mode must not read element payload bytes"
+ "descriptor mode must not read element payload bytes beyond the
index prefetch"
);
}
@@ -2950,6 +3143,7 @@ mod tests {
let overlapping_reads = reader
.ranges()
.into_iter()
+ .skip(1) // Exclude the bounded index prefetch.
.filter(|range| {
range.start < element_data_range.end &&
element_data_range.start < range.end
})
@@ -3099,6 +3293,7 @@ mod tests {
let payload_reads = reader
.ranges()
.into_iter()
+ .skip(1) // The bounded index prefetch may include payload bytes.
.filter(|range| range.start < payload_end && range.end > 0)
.collect::<Vec<_>>();
assert_eq!(payload_reads, vec![0..payload_end]);