Repository navigation
feat(parquet): Add async Sbbf::read_from_column_chunk_async Bloom filter API
#11953
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change | ||||||
|---|---|---|---|---|---|---|---|---|
|
|
@@ -112,6 +112,8 @@ | |||||||
| //! [sbbf-paper]: https://arxiv.org/pdf/2101.01719 | ||||||||
| //! [bf-formulae]: http://tfk.mit.edu/pdf/bloom.pdf | ||||||||
|
|
||||||||
| #[cfg(all(feature = "arrow", feature = "async"))] | ||||||||
| use crate::arrow::async_reader::AsyncFileReader; | ||||||||
| use crate::basic::{BloomFilterAlgorithm, BloomFilterCompression, BloomFilterHash}; | ||||||||
| use crate::data_type::AsBytes; | ||||||||
| use crate::errors::{ParquetError, Result}; | ||||||||
|
|
@@ -520,6 +522,82 @@ impl Sbbf { | |||||||
| Ok(Some(Self::new(&bitset))) | ||||||||
| } | ||||||||
|
|
||||||||
| /// Read a Bloom filter asynchronously from existing column-chunk metadata. | ||||||||
| /// | ||||||||
| /// The metadata and reader must refer to the same file. | ||||||||
| /// | ||||||||
| /// A missing Bloom filter offset returns `None` without performing I/O. Invalid | ||||||||
| /// metadata, malformed filters, and read failures return an error. | ||||||||
| /// | ||||||||
| /// When the total filter length is present, one range is fetched. Otherwise, | ||||||||
| /// this may perform additional fetches. | ||||||||
| #[cfg(all(feature = "arrow", feature = "async"))] | ||||||||
| pub async fn read_from_column_chunk_async<R: AsyncFileReader + ?Sized>( | ||||||||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This looks very similar to the What would you think about pulling the parsing logic into "PushDecoder" (and then using that same decoder for the sync and async APIs)? Something like this /// Decodes a Bloom filter from bytes pushed by the caller
pub struct SbbfPushDecoder { /* state machine */ }
impl SbbfPushDecoder {
/// Returns `None` if the column chunk has no Bloom filter
pub fn try_new(column_metadata: &ColumnChunkMetaData) -> Result<Option<Self>>;
pub fn push_range(&mut self, range: Range<u64>, buffer: Bytes) -> Result<()>;
pub fn push_ranges(&mut self, ranges: Vec<Range<u64>>, buffers: Vec<Bytes>) -> Result<()>;
/// `NeedsData` once if `bloom_filter_length` is known, twice otherwise
/// (header estimate, then the bitset)
pub fn try_decode(&mut self) -> Result<DecodeResult<Sbbf>>;
}Then the new method would just look like this: let Some(mut decoder) = SbbfPushDecoder::try_new(column_metadata)? else {
return Ok(None);
};
loop {
match decoder.try_decode()? {
DecodeResult::NeedsData(ranges) => {
let buffers = reader.get_byte_ranges(ranges.clone()).await?;
decoder.push_ranges(ranges, buffers)?;
}
DecodeResult::Data(sbbf) => return Ok(Some(sbbf)),
DecodeResult::Finished => return Err(general_err!("...")),
}
}And we could consolidate testing as we wouldn't have multiple independent copies to test. This would also give us an extensible API if we wanted to add more controls over what bloom filters were actually read. Locations of bloom filter loading copies DetailsCopy: Sbbf::read_from_column_chunk (sync standalone) arrow-rs/parquet/src/bloom_filter/mod.rs Line 476 in bf0cde3
──────────────────────────────────────── Copy: ParquetRecordBatchReaderBuilder::get_row_group_column_bloom_filter (sync builder) On main: yes In PR: yes, unchanged Link: arrow-rs/parquet/src/arrow/arrow_reader/mod.rs Line 1348 in bf0cde3
──────────────────────────────────────── Copy: ParquetRecordBatchStreamBuilder::get_row_group_column_bloom_filter (async builder, inline copy) On main: yes In PR: no, replaced by a one-line delegation Link:
──────────────────────────────────────── Copy: Sbbf::read_from_column_chunk_async (async standalone, new) On main: no In PR: yes Link: https://github.com/apache/arrow-rs/blob/948390caa2/parquet/src/bloom_filter/mod.rs#L535 |
||||||||
| column_metadata: &ColumnChunkMetaData, | ||||||||
| reader: &mut R, | ||||||||
| ) -> Result<Option<Self>> { | ||||||||
| let Some(offset) = column_metadata.bloom_filter_offset() else { | ||||||||
| return Ok(None); | ||||||||
| }; | ||||||||
| let offset = | ||||||||
| u64::try_from(offset).map_err(|_| general_err!("Bloom filter offset is invalid"))?; | ||||||||
| let length = column_metadata | ||||||||
| .bloom_filter_length() | ||||||||
| .map(|length| { | ||||||||
| usize::try_from(length) | ||||||||
| .ok() | ||||||||
| .filter(|length| *length > 0) | ||||||||
| .ok_or_else(|| general_err!("Bloom filter length is invalid")) | ||||||||
| }) | ||||||||
| .transpose()?; | ||||||||
| let buffer = read_bloom_filter_bytes_async( | ||||||||
| reader, | ||||||||
| offset, | ||||||||
| length.unwrap_or(SBBF_HEADER_SIZE_ESTIMATE), | ||||||||
| ) | ||||||||
| .await?; | ||||||||
| let (header, header_length) = read_bloom_filter_header_and_length(buffer.clone())?; | ||||||||
|
|
||||||||
| // The following matches are present to force an explicit decision | ||||||||
| // if supported variants change. | ||||||||
| match header.algorithm { | ||||||||
| BloomFilterAlgorithm::BLOCK => {} | ||||||||
| } | ||||||||
| match header.hash { | ||||||||
| BloomFilterHash::XXHASH => {} | ||||||||
| } | ||||||||
| match header.compression { | ||||||||
| BloomFilterCompression::UNCOMPRESSED => {} | ||||||||
| } | ||||||||
|
|
||||||||
| let bitset_length = usize::try_from(header.num_bytes) | ||||||||
| .map_err(|_| general_err!("Bloom filter bitset length is invalid"))?; | ||||||||
| if bitset_length < BITSET_MIN_LENGTH || !bitset_length.is_multiple_of(32) { | ||||||||
| return Err(general_err!( | ||||||||
| "Bloom filter bitset length must be a positive multiple of 32" | ||||||||
| )); | ||||||||
| } | ||||||||
| let bitset_offset = offset | ||||||||
| .checked_add(header_length) | ||||||||
| .ok_or_else(|| general_err!("Bloom filter offset overflow"))?; | ||||||||
| let bitset = if let Some(length) = length { | ||||||||
| let header_length = usize::try_from(header_length) | ||||||||
| .map_err(|_| general_err!("Bloom filter header length is invalid"))?; | ||||||||
| let total = header_length | ||||||||
| .checked_add(bitset_length) | ||||||||
| .ok_or_else(|| general_err!("Bloom filter length overflow"))?; | ||||||||
| if total != length { | ||||||||
| return Err(general_err!( | ||||||||
| "Bloom filter length does not match its header" | ||||||||
| )); | ||||||||
| } | ||||||||
| buffer.slice(header_length..total) | ||||||||
| } else { | ||||||||
| read_bloom_filter_bytes_async(reader, bitset_offset, bitset_length).await? | ||||||||
| }; | ||||||||
| Ok(Some(Self::new(&bitset))) | ||||||||
| } | ||||||||
|
|
||||||||
| /// Map a 64-bit hash to a block index in `[0, num_blocks)`. | ||||||||
| /// | ||||||||
| /// Uses the "multiply-and-shift" trick (a fast alternative to modulo): | ||||||||
|
|
@@ -778,6 +856,25 @@ impl Sbbf { | |||||||
| } | ||||||||
| } | ||||||||
|
|
||||||||
| #[cfg(all(feature = "arrow", feature = "async"))] | ||||||||
| async fn read_bloom_filter_bytes_async<R: AsyncFileReader + ?Sized>( | ||||||||
| reader: &mut R, | ||||||||
| offset: u64, | ||||||||
| length: usize, | ||||||||
| ) -> Result<Bytes> { | ||||||||
| let end = offset | ||||||||
| .checked_add(length as u64) | ||||||||
| .ok_or_else(|| general_err!("Bloom filter read range overflow"))?; | ||||||||
| let bytes = reader.get_bytes(offset..end).await?; | ||||||||
| if bytes.len() != length { | ||||||||
| return Err(general_err!( | ||||||||
| "Bloom filter read length mismatch: expected {length} bytes, got {}", | ||||||||
| bytes.len() | ||||||||
| )); | ||||||||
| } | ||||||||
| Ok(bytes) | ||||||||
| } | ||||||||
|
|
||||||||
| // per spec we use xxHash with seed=0 | ||||||||
| const SEED: u64 = 0; | ||||||||
|
|
||||||||
|
|
@@ -790,6 +887,21 @@ fn hash_as_bytes<A: AsBytes + ?Sized>(value: &A) -> u64 { | |||||||
| mod tests { | ||||||||
| use super::*; | ||||||||
|
|
||||||||
| #[cfg(all(feature = "arrow", feature = "async"))] | ||||||||
| #[tokio::test] | ||||||||
| async fn test_async_bloom_filter_range_overflow_before_io() { | ||||||||
| let mut reader = std::io::Cursor::new(Vec::<u8>::new()); | ||||||||
| let error = read_bloom_filter_bytes_async(&mut reader, u64::MAX, 1) | ||||||||
| .await | ||||||||
| .unwrap_err(); | ||||||||
| assert!( | ||||||||
| error | ||||||||
| .to_string() | ||||||||
| .contains("Bloom filter read range overflow") | ||||||||
| ); | ||||||||
| assert_eq!(reader.position(), 0); | ||||||||
| } | ||||||||
|
|
||||||||
| #[test] | ||||||||
| fn test_hash_bytes() { | ||||||||
| assert_eq!(hash_as_bytes(""), 17241709254077376921); | ||||||||
|
|
||||||||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The PR basically pulls this code into a new public API for reuse which I think a good step forward. However, I have another idea for consolidation that I think could be even nicer