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 b9bab593 perf(btree): load null bitmap on demand (#1008)
b9bab593 is described below
commit b9bab5934b3f4521ede3f36e2b8a89f2f770c15b
Author: QuakeWang <[email protected]>
AuthorDate: Sat Oct 3 18:07:10 2026 +0800
perf(btree): load null bitmap on demand (#1008)
---
crates/paimon/src/btree/mod.rs | 6 +-
crates/paimon/src/btree/query.rs | 2 +-
crates/paimon/src/btree/reader.rs | 38 ++-
crates/paimon/src/btree/tests.rs | 336 ++++++++++++++++++++-
.../paimon/src/table/global_index_scanner/tests.rs | 119 +++++++-
docs/src/releases.md | 5 +
6 files changed, 481 insertions(+), 25 deletions(-)
diff --git a/crates/paimon/src/btree/mod.rs b/crates/paimon/src/btree/mod.rs
index ddee2d62..9baa8662 100644
--- a/crates/paimon/src/btree/mod.rs
+++ b/crates/paimon/src/btree/mod.rs
@@ -23,13 +23,13 @@
//! | Footer | |
//! +-----------------------------------+ |
//! | Index Block | +--> Loaded on open
-//! +-----------------------------------+ |
-//! | Bloom Filter Block | |
//! +-----------------------------------+------+
+//! | Bloom Filter Block | |
+//! +-----------------------------------+ |
//! | Null Bitmap Block | |
//! +-----------------------------------+ |
//! | Data Block | |
-//! +-----------------------------------+ +--> Loaded on requested
+//! +-----------------------------------+ +--> Loaded on demand
//! | ...... | |
//! +-----------------------------------+ |
//! | Data Block | |
diff --git a/crates/paimon/src/btree/query.rs b/crates/paimon/src/btree/query.rs
index b5166b5a..4d377bc8 100644
--- a/crates/paimon/src/btree/query.rs
+++ b/crates/paimon/src/btree/query.rs
@@ -79,7 +79,7 @@ where
let key_refs: Vec<&[u8]> = keys.iter().map(|k|
k.as_slice()).collect();
self.query_in(&key_refs).await
}
- PredicateOperator::IsNull => Ok(self.null_bitmap().clone()),
+ PredicateOperator::IsNull => Ok(self.null_bitmap().await?.clone()),
PredicateOperator::IsNotNull => self.all_non_null_rows().await,
PredicateOperator::NotEq => {
let key = serialize_datum(&literals[0], data_type);
diff --git a/crates/paimon/src/btree/reader.rs
b/crates/paimon/src/btree/reader.rs
index 2e8f30e9..579190c1 100644
--- a/crates/paimon/src/btree/reader.rs
+++ b/crates/paimon/src/btree/reader.rs
@@ -20,7 +20,7 @@
//! Supports:
//! - Point lookup (equal)
//! - Range queries (less than, greater than, between, etc.)
-//! - Null bitmap reading
+//! - On-demand null bitmap reading
//! - IN / NOT IN queries
use crate::btree::block::{BlockHandle, BlockReader};
@@ -64,11 +64,13 @@ fn try_sort_by<T>(
}
}
-/// BTree index reader with on-demand async data block loading.
+/// BTree index reader with on-demand async block loading.
pub struct BTreeIndexReader<F: Fn(&[u8], &[u8]) -> crate::Result<Ordering>> {
reader: Box<dyn FileRead>,
sst_reader: SstFileReader,
- null_bitmap: RoaringTreemap,
+ null_bitmap_handle: Option<BlockHandle>,
+ // Keep lazy synchronization state from inflating enums that embed this
reader.
+ null_bitmap: Box<OnceCell<RoaringTreemap>>,
min_key: Option<Vec<u8>>,
max_key: Option<Vec<u8>>,
key_comparator: F,
@@ -80,8 +82,8 @@ pub struct BTreeIndexReader<F: Fn(&[u8], &[u8]) ->
crate::Result<Ordering>> {
impl<F: Fn(&[u8], &[u8]) -> crate::Result<Ordering>> BTreeIndexReader<F> {
/// Open a BTree index reader from a FileRead and file metadata.
- /// Only reads footer, index block, and null bitmap on open.
- /// Data blocks are read on demand during queries.
+ /// Only reads the footer and index block on open.
+ /// Data blocks, the Bloom filter, and the null bitmap are read on demand.
pub async fn open(
reader: Box<dyn FileRead>,
file_size: u64,
@@ -136,16 +138,11 @@ impl<F: Fn(&[u8], &[u8]) -> crate::Result<Ordering>>
BTreeIndexReader<F> {
let index_block = read_block_from_bytes(&index_bytes, idx.size)?;
let sst_reader = SstFileReader::from_index_block(index_block);
- // 3. Read null bitmap
- let null_bitmap = match &footer.null_bitmap_handle {
- Some(h) => read_null_bitmap(reader.as_ref(), h).await?,
- None => RoaringTreemap::new(),
- };
-
Ok(Self {
reader,
sst_reader,
- null_bitmap,
+ null_bitmap_handle: footer.null_bitmap_handle,
+ null_bitmap: Box::new(OnceCell::new()),
min_key: meta.first_key.clone(),
max_key: meta.last_key.clone(),
key_comparator,
@@ -156,9 +153,20 @@ impl<F: Fn(&[u8], &[u8]) -> crate::Result<Ordering>>
BTreeIndexReader<F> {
})
}
- /// Get the null bitmap (row ids of null keys).
- pub fn null_bitmap(&self) -> &RoaringTreemap {
- &self.null_bitmap
+ /// Load the null bitmap (row ids of null keys) on first access.
+ /// Concurrent callers share successful initialization within this reader.
+ /// I/O, CRC, and deserialization errors are returned here rather than on
open;
+ /// failed loads are not cached and may be retried. An absent block
returns an
+ /// empty bitmap without reading the file.
+ pub async fn null_bitmap(&self) -> io::Result<&RoaringTreemap> {
+ self.null_bitmap
+ .get_or_try_init(|| async {
+ match &self.null_bitmap_handle {
+ Some(handle) => read_null_bitmap(self.reader.as_ref(),
handle).await,
+ None => Ok(RoaringTreemap::new()),
+ }
+ })
+ .await
}
/// Compare two keys, reporting a stored key that cannot belong to the
column's
diff --git a/crates/paimon/src/btree/tests.rs b/crates/paimon/src/btree/tests.rs
index c9a0f20c..cf43a9cc 100644
--- a/crates/paimon/src/btree/tests.rs
+++ b/crates/paimon/src/btree/tests.rs
@@ -262,6 +262,327 @@ fn read_footer(data: &[u8]) -> BTreeFileFooter {
BTreeFileFooter::read_footer(&data[data.len() -
BTREE_FOOTER_ENCODED_LENGTH..]).unwrap()
}
+#[tokio::test]
+async fn test_null_bitmap_is_loaded_on_demand() {
+ let data_type = DataType::Int(crate::spec::IntType::new());
+ let key = |value| crate::btree::serialize_datum(&Datum::Int(value),
&data_type);
+
+ for version in [1, 2] {
+ for (has_nulls, has_non_nulls) in [(true, true), (false, true), (true,
false)] {
+ let buf = VecFileWrite::new();
+ let mut writer = BTreeIndexWriter::with_comparator(
+ Box::new(buf.clone()),
+ 256,
+ BlockCompressionType::None,
+ crate::btree::make_key_comparator(&data_type),
+ )
+ .with_file_version(version)
+ .unwrap();
+ if has_nulls {
+ writer.write(None, 10).await.unwrap();
+ writer.write(None, 30).await.unwrap();
+ }
+ if has_non_nulls {
+ writer.write(Some(&key(1)), 20).await.unwrap();
+ writer.write(Some(&key(2)), 40).await.unwrap();
+ }
+ let result = writer.finish().await.unwrap();
+ let data = Bytes::from(buf.to_vec());
+ let footer = read_footer(&data);
+ let null_range = footer
+ .null_bitmap_handle
+ .map(|handle| handle.offset..handle.offset +
u64::from(handle.size) + 4);
+ assert_eq!(null_range.is_some(), has_nulls);
+ let ranges = Arc::new(Mutex::new(Vec::new()));
+ let reader = BTreeIndexReader::open(
+ Box::new(RecordingFileRead {
+ data: data.clone(),
+ ranges: ranges.clone(),
+ }),
+ data.len() as u64,
+ &result.meta,
+ crate::btree::make_key_comparator(&data_type),
+ )
+ .await
+ .unwrap();
+
+ let index = footer.index_block_handle;
+ let expected_open_ranges = vec![
+ data.len() as u64 - BTREE_FOOTER_ENCODED_LENGTH as
u64..data.len() as u64,
+ index.offset..index.offset + index.full_block_size() as u64,
+ ];
+ assert_eq!(*ranges.lock().unwrap(), expected_open_ranges);
+ let null_reads = || {
+ ranges
+ .lock()
+ .unwrap()
+ .iter()
+ .filter(|range| null_range.as_ref() == Some(*range))
+ .count()
+ };
+ assert_eq!(null_reads(), 0);
+
+ let expected_equal = if has_non_nulls { vec![20] } else { vec![] };
+ let expected_non_null = if has_non_nulls { vec![20, 40] } else {
vec![] };
+ for (op, literals, expected) in [
+ (PredicateOperator::Eq, vec![Datum::Int(1)], expected_equal),
+ (
+ PredicateOperator::Between,
+ vec![Datum::Int(1), Datum::Int(2)],
+ expected_non_null.clone(),
+ ),
+ (PredicateOperator::IsNotNull, vec![], expected_non_null),
+ ] {
+ let rows = reader.query(op, &literals,
&data_type).await.unwrap();
+ assert_eq!(rows.iter().collect::<Vec<_>>(), expected,
"{op:?}");
+ assert_eq!(null_reads(), 0, "{op:?}");
+ }
+
+ let mut expected_reads = ranges.lock().unwrap().clone();
+ let expected_null = if has_nulls { vec![10, 30] } else { vec![] };
+ let rows = reader
+ .query(PredicateOperator::IsNull, &[], &data_type)
+ .await
+ .unwrap();
+ assert_eq!(rows.iter().collect::<Vec<_>>(), expected_null);
+ if let Some(range) = &null_range {
+ expected_reads.push(range.clone());
+ }
+ assert_eq!(*ranges.lock().unwrap(), expected_reads);
+ assert_eq!(null_reads(), usize::from(has_nulls));
+ for _ in 0..2 {
+ let rows = reader
+ .query(PredicateOperator::IsNull, &[], &data_type)
+ .await
+ .unwrap();
+ assert_eq!(rows.iter().collect::<Vec<_>>(), expected_null);
+ }
+ let (left, right) = tokio::join!(
+ reader.query(PredicateOperator::IsNull, &[], &data_type),
+ reader.query(PredicateOperator::IsNull, &[], &data_type),
+ );
+ assert_eq!(left.unwrap().iter().collect::<Vec<_>>(),
expected_null);
+ assert_eq!(right.unwrap().iter().collect::<Vec<_>>(),
expected_null);
+ assert_eq!(*ranges.lock().unwrap(), expected_reads);
+ }
+ }
+}
+
+#[tokio::test]
+async fn test_null_bitmap_corruption_is_reported_on_access() {
+ for version in [1, 2] {
+ for corrupt_crc in [true, false] {
+ let buf = VecFileWrite::new();
+ let mut writer =
+ BTreeIndexWriter::new(Box::new(buf.clone()), 256,
BlockCompressionType::None)
+ .with_file_version(version)
+ .unwrap();
+ writer.write(None, 10).await.unwrap();
+ writer.write(Some(&int_key(1)), 20).await.unwrap();
+ let result = writer.finish().await.unwrap();
+ let mut data = buf.to_vec();
+ let footer = read_footer(&data);
+ let handle = footer.null_bitmap_handle.unwrap();
+ let bitmap_start = handle.offset as usize;
+ let crc_start = bitmap_start + handle.size as usize;
+ if corrupt_crc {
+ data[crc_start] ^= 1;
+ } else {
+ // The first bitmap cookie follows the u64 map count and u32
key.
+ let cookie_start = bitmap_start + 12;
+ data[cookie_start..cookie_start + 4].fill(0);
+ let crc = crc32fast::hash(&data[bitmap_start..crc_start]);
+ data[crc_start..crc_start +
4].copy_from_slice(&crc.to_le_bytes());
+ assert!(
+
roaring::RoaringTreemap::deserialize_from(&data[bitmap_start..crc_start])
+ .is_err()
+ );
+ }
+ let data = Bytes::from(data);
+ let ranges = Arc::new(Mutex::new(Vec::new()));
+ let reader = BTreeIndexReader::open(
+ Box::new(RecordingFileRead {
+ data: data.clone(),
+ ranges: ranges.clone(),
+ }),
+ data.len() as u64,
+ &result.meta,
+ int_cmp,
+ )
+ .await
+ .expect("null bitmap corruption must not prevent opening the
reader");
+ assert_eq!(ranges.lock().unwrap().len(), 2);
+ assert_eq!(
+ reader
+ .query_equal(&int_key(1))
+ .await
+ .unwrap()
+ .iter()
+ .collect::<Vec<_>>(),
+ vec![20],
+ );
+ let null_range = handle.offset..handle.offset +
u64::from(handle.size) + 4;
+ assert!(!ranges.lock().unwrap().contains(&null_range));
+
+ let data_type = DataType::Int(crate::spec::IntType::new());
+ for _ in 0..2 {
+ let error = reader
+ .query(PredicateOperator::IsNull, &[], &data_type)
+ .await
+ .expect_err("null bitmap corruption must not produce an
empty result");
+ assert_eq!(error.kind(), std::io::ErrorKind::InvalidData);
+ if corrupt_crc {
+ assert!(error.to_string().contains("Null bitmap CRC
mismatch"));
+ } else {
+ assert!(error.to_string().contains("unknown cookie
value"));
+ }
+ }
+ assert_eq!(
+ ranges
+ .lock()
+ .unwrap()
+ .iter()
+ .filter(|range| **range == null_range)
+ .count(),
+ 2,
+ "failed initialization must not be cached",
+ );
+ }
+ }
+}
+
+#[tokio::test]
+async fn test_null_bitmap_read_failure_can_be_retried() {
+ struct FailingNullBitmapRead {
+ inner: RecordingFileRead,
+ null_range: Range<u64>,
+ null_reads: AtomicUsize,
+ }
+
+ #[async_trait::async_trait]
+ impl FileRead for FailingNullBitmapRead {
+ async fn read(&self, range: Range<u64>) -> crate::Result<Bytes> {
+ if range == self.null_range && self.null_reads.fetch_add(1,
AtomicOrdering::SeqCst) == 0
+ {
+ self.inner.ranges.lock().unwrap().push(range);
+ return Err(crate::Error::UnexpectedError {
+ message: "Injected null bitmap read failure".to_string(),
+ source: None,
+ });
+ }
+ self.inner.read(range).await
+ }
+ }
+
+ let buf = VecFileWrite::new();
+ let mut writer = BTreeIndexWriter::new(Box::new(buf.clone()), 256,
BlockCompressionType::None);
+ writer.write(None, 10).await.unwrap();
+ writer.write(Some(&int_key(1)), 20).await.unwrap();
+ let result = writer.finish().await.unwrap();
+ let data = Bytes::from(buf.to_vec());
+ let handle = read_footer(&data).null_bitmap_handle.unwrap();
+ let null_range = handle.offset..handle.offset + u64::from(handle.size) + 4;
+ let ranges = Arc::new(Mutex::new(Vec::new()));
+ let reader = BTreeIndexReader::open(
+ Box::new(FailingNullBitmapRead {
+ inner: RecordingFileRead {
+ data: data.clone(),
+ ranges: ranges.clone(),
+ },
+ null_range: null_range.clone(),
+ null_reads: AtomicUsize::new(0),
+ }),
+ data.len() as u64,
+ &result.meta,
+ int_cmp,
+ )
+ .await
+ .expect("opening must not read the null bitmap");
+ assert_eq!(ranges.lock().unwrap().len(), 2);
+ let data_type = DataType::Int(crate::spec::IntType::new());
+ let error = reader
+ .query(PredicateOperator::IsNull, &[], &data_type)
+ .await
+ .expect_err("the first null bitmap load must return the injected I/O
error");
+ assert_eq!(error.kind(), std::io::ErrorKind::Other);
+ assert!(error
+ .to_string()
+ .contains("Injected null bitmap read failure"));
+ assert_eq!(ranges.lock().unwrap().len(), 3);
+ assert_eq!(ranges.lock().unwrap().last(), Some(&null_range));
+ assert_eq!(
+ reader
+ .query_equal(&int_key(1))
+ .await
+ .unwrap()
+ .iter()
+ .collect::<Vec<_>>(),
+ vec![20],
+ );
+ let rows = reader
+ .query(PredicateOperator::IsNull, &[], &data_type)
+ .await
+ .expect("a failed load must allow a successful retry");
+ assert_eq!(rows.iter().collect::<Vec<_>>(), vec![10]);
+ assert_eq!(
+ ranges
+ .lock()
+ .unwrap()
+ .iter()
+ .filter(|range| **range == null_range)
+ .count(),
+ 2,
+ );
+ let reads_after_success = ranges.lock().unwrap().clone();
+ assert_eq!(reader.null_bitmap().await.unwrap(), &rows);
+ assert_eq!(*ranges.lock().unwrap(), reads_after_success);
+}
+
+#[tokio::test]
+async fn test_null_bitmap_concurrent_first_queries_share_one_read() {
+ struct YieldingFileRead(RecordingFileRead);
+
+ #[async_trait::async_trait]
+ impl FileRead for YieldingFileRead {
+ async fn read(&self, range: Range<u64>) -> crate::Result<Bytes> {
+ let bytes = self.0.read(range).await?;
+ tokio::task::yield_now().await;
+ Ok(bytes)
+ }
+ }
+
+ let buf = VecFileWrite::new();
+ let mut writer = BTreeIndexWriter::new(Box::new(buf.clone()), 256,
BlockCompressionType::None);
+ writer.write(None, 10).await.unwrap();
+ writer.write(None, 30).await.unwrap();
+ let result = writer.finish().await.unwrap();
+ let data = Bytes::from(buf.to_vec());
+ let handle = read_footer(&data).null_bitmap_handle.unwrap();
+ let null_range = handle.offset..handle.offset + u64::from(handle.size) + 4;
+ let ranges = Arc::new(Mutex::new(Vec::new()));
+ let reader = BTreeIndexReader::open(
+ Box::new(YieldingFileRead(RecordingFileRead {
+ data: data.clone(),
+ ranges: ranges.clone(),
+ })),
+ data.len() as u64,
+ &result.meta,
+ int_cmp,
+ )
+ .await
+ .unwrap();
+ assert_eq!(ranges.lock().unwrap().len(), 2);
+ let data_type = DataType::Int(crate::spec::IntType::new());
+ let (left, right) = tokio::join!(
+ reader.query(PredicateOperator::IsNull, &[], &data_type),
+ reader.query(PredicateOperator::IsNull, &[], &data_type),
+ );
+ assert_eq!(left.unwrap().iter().collect::<Vec<_>>(), vec![10, 30]);
+ assert_eq!(right.unwrap().iter().collect::<Vec<_>>(), vec![10, 30]);
+ assert_eq!(ranges.lock().unwrap().len(), 3);
+ assert_eq!(ranges.lock().unwrap().last(), Some(&null_range));
+}
+
#[tokio::test]
async fn test_java_v2_posting_encodings() {
// Existing low-level tests use arbitrary big-endian keys. Java's INT key
@@ -295,7 +616,12 @@ async fn test_java_v2_posting_encodings() {
);
}
assert_eq!(
- reader.null_bitmap().iter().collect::<Vec<_>>(),
+ reader
+ .null_bitmap()
+ .await
+ .unwrap()
+ .iter()
+ .collect::<Vec<_>>(),
vec![4, (1u64 << 32) + 20000]
);
assert_eq!(
@@ -581,7 +907,7 @@ async fn test_null_keys() {
let reader = write_and_open(&buf, &result, int_cmp).await;
- let null_bm = reader.null_bitmap();
+ let null_bm = reader.null_bitmap().await.unwrap();
assert!(null_bm.contains(10));
assert!(null_bm.contains(30));
assert!(!null_bm.contains(20));
@@ -605,7 +931,7 @@ async fn test_only_nulls() {
assert!(result.meta.has_nulls);
let reader = write_and_open(&buf, &result, int_cmp).await;
- let null_bm = reader.null_bitmap();
+ let null_bm = reader.null_bitmap().await.unwrap();
assert_eq!(null_bm.len(), 3);
}
@@ -1303,7 +1629,7 @@ async fn test_nulls_with_range_query() {
assert!(!all.contains(0));
assert!(!all.contains(100));
- let nulls = reader.null_bitmap();
+ let nulls = reader.null_bitmap().await.unwrap();
assert_eq!(nulls.len(), 2);
assert!(nulls.contains(0));
assert!(nulls.contains(100));
@@ -1432,7 +1758,7 @@ async fn test_java_compat_int_with_nulls() {
let meta = BTreeIndexMeta::new(Some(le_int_key(0)), Some(le_int_key(198)),
true);
let reader = open_testdata("btree_int_100_with_nulls.bin", &meta,
le_int_cmp).await;
- let null_bm = reader.null_bitmap();
+ let null_bm = reader.null_bitmap().await.unwrap();
assert!(!null_bm.is_empty());
let all = reader.all_non_null_rows().await.unwrap();
diff --git a/crates/paimon/src/table/global_index_scanner/tests.rs
b/crates/paimon/src/table/global_index_scanner/tests.rs
index 84ebbacd..2870bb1b 100644
--- a/crates/paimon/src/table/global_index_scanner/tests.rs
+++ b/crates/paimon/src/table/global_index_scanner/tests.rs
@@ -21,7 +21,9 @@ use super::predicates::*;
use super::row_ranges::*;
use super::*;
use crate::btree::test_util::VecFileWrite;
-use crate::btree::{serialize_datum, BTreeIndexWriter, BlockCompressionType};
+use crate::btree::{
+ make_key_comparator, serialize_datum, BTreeFileFooter, BTreeIndexWriter,
BlockCompressionType,
+};
use crate::fm_index::{FMGlobalIndexWriter, FMWriteOptions};
use crate::spec::{DataType, Datum, IndexFileMeta, PredicateOperator};
use crate::table::bitmap_global_index_format::{
@@ -1489,6 +1491,121 @@ async fn test_evaluate_global_index_eq() {
assert_eq!(ranges, vec![RowRange::new(25, 25)]);
}
+#[tokio::test]
+async fn test_evaluate_btree_null_bitmap_and_deferred_crc_error() {
+ let data_type = DataType::Int(crate::spec::IntType::new());
+ for version in [1, 2] {
+ for corrupt_crc in [false, true] {
+ let buf = VecFileWrite::new();
+ let mut writer = BTreeIndexWriter::with_comparator(
+ Box::new(buf.clone()),
+ 256,
+ BlockCompressionType::None,
+ make_key_comparator(&data_type),
+ )
+ .with_file_version(version)
+ .unwrap();
+ writer.write(None, 0).await.unwrap();
+ writer.write(Some(&le_int_key(1)), 1).await.unwrap();
+ writer.write(Some(&le_int_key(2)), 2).await.unwrap();
+ writer.write(None, 3).await.unwrap();
+ let result = writer.finish().await.unwrap();
+ let mut data = buf.to_vec();
+ if corrupt_crc {
+ let footer = BTreeFileFooter::read_footer(
+ &data[data.len() - BTreeFileFooter::ENCODED_LENGTH..],
+ )
+ .unwrap();
+ let handle = footer.null_bitmap_handle.unwrap();
+ data[handle.offset as usize + handle.size as usize] ^= 1;
+ }
+ let file_io =
crate::io::FileIOBuilder::new("memory").build().unwrap();
+ let table_path = "memory:/table";
+ let file_name = "nullable.btree";
+ let mut entry = make_global_index_entry(file_name, 1, 100, 103,
&result.meta);
+ entry.index_file.file_size = data.len() as i64;
+ entry.index_file.row_count = result.row_count as i64;
+ file_io
+ .new_output(&format!("{table_path}/index/{file_name}"))
+ .unwrap()
+ .write(bytes::Bytes::from(data))
+ .await
+ .unwrap();
+ let entries = [entry];
+ let fields = int_schema_fields();
+
+ for (op, literals, expected) in [
+ (
+ PredicateOperator::Eq,
+ vec![Datum::Int(1)],
+ vec![RowRange::new(101, 101)],
+ ),
+ (
+ PredicateOperator::Between,
+ vec![Datum::Int(1), Datum::Int(2)],
+ vec![RowRange::new(101, 102)],
+ ),
+ (
+ PredicateOperator::IsNotNull,
+ vec![],
+ vec![RowRange::new(101, 102)],
+ ),
+ ] {
+ let predicate = Predicate::Leaf {
+ column: "id".to_string(),
+ index: 0,
+ data_type: data_type.clone(),
+ op,
+ literals,
+ };
+ let rows = evaluate_global_index_fast(
+ &file_io,
+ table_path,
+ &entries,
+ &[predicate],
+ &fields,
+ )
+ .await
+ .unwrap();
+ assert_eq!(
+ rows,
+ Some(expected),
+ "{op:?}, V{version}, CRC={corrupt_crc}"
+ );
+ }
+
+ let predicate = Predicate::Leaf {
+ column: "id".to_string(),
+ index: 0,
+ data_type: data_type.clone(),
+ op: PredicateOperator::IsNull,
+ literals: vec![],
+ };
+ let rows =
+ evaluate_global_index_fast(&file_io, table_path, &entries,
&[predicate], &fields)
+ .await;
+ if corrupt_crc {
+ let error = rows.expect_err("a corrupt null bitmap must fail
the IS NULL scan");
+ let Error::DataInvalid {
+ message,
+ source: Some(source),
+ } = error
+ else {
+ panic!("expected a global index query error with its I/O
source");
+ };
+ assert!(message.contains("Global index query failed"));
+ assert!(message.contains(file_name));
+ assert!(source.to_string().contains("Null bitmap CRC
mismatch"));
+ } else {
+ assert_eq!(
+ rows.unwrap(),
+ Some(vec![RowRange::new(100, 100), RowRange::new(103,
103)]),
+ );
+ }
+ }
+ }
+}
+
#[tokio::test]
async fn test_evaluate_global_index_uses_known_file_size() {
let (file_io, table_path, file_name, _tmp) =
diff --git a/docs/src/releases.md b/docs/src/releases.md
index 355ac14c..4202ddf9 100644
--- a/docs/src/releases.md
+++ b/docs/src/releases.md
@@ -42,6 +42,11 @@ Key features:
- Standalone `BlobDescriptor` batch reads and seekable streams, with Go batch
and stream APIs and `MAP<STRING, BLOB>` descriptor reads
- Extensible and cached I/O through caller-provided OpenDAL operators, an
in-memory local block cache, and C cache callbacks
+API compatibility changes:
+
+- `BTreeIndexReader::null_bitmap()` is now asynchronous and returns
`io::Result<&RoaringTreemap>`. Downstream Rust callers must replace
`reader.null_bitmap()` with `reader.null_bitmap().await?` and handle load
errors.
+- BTree readers load the null bitmap on first access and reuse successful
loads within the reader. Null bitmap I/O, CRC, and deserialization errors now
surface on first access rather than when opening the reader. Failed loads can
be retried.
+
## Past Releases
### [0.3.0](https://github.com/apache/paimon-rust/releases/tag/v0.3.0)