mbutrovich commented on code in PR #2398:
URL: https://github.com/apache/iceberg-rust/pull/2398#discussion_r3982500414
##########
crates/iceberg/src/arrow/reader/pipeline.rs:
##########
@@ -692,6 +721,69 @@ impl FileScanTaskReader {
Ok(Box::pin(record_batch_stream) as ArrowRecordBatchStream)
}
+
+ /// Reads bloom filters for relevant columns and evaluates the predicate
+ /// against them to filter out row groups that definitely don't match.
+ async fn filter_row_groups_by_bloom_filter(
+ predicate: &crate::expr::BoundPredicate,
+ builder: &mut ParquetRecordBatchStreamBuilder<ArrowFileReader>,
+ candidate_row_groups: &[usize],
+ field_id_map: &HashMap<i32, usize>,
+ ) -> Result<Vec<usize>> {
+ // Only collect field IDs from eq/in predicates — the only types
+ // bloom filters can help with. Skip columns not in the parquet schema.
+ let bloom_filter_field_ids: Vec<i32> =
collect_bloom_filter_field_ids(predicate)?
+ .into_iter()
+ .filter(|id| field_id_map.contains_key(id))
+ .collect();
+
+ if bloom_filter_field_ids.is_empty() {
+ return Ok(candidate_row_groups.to_vec());
+ }
+
+ let mut result = Vec::with_capacity(candidate_row_groups.len());
+
+ for &rg_idx in candidate_row_groups {
Review Comment:
Not a new comment, a response to your question about deferring.
Deferring seems right to me. `get_row_group_column_bloom_filter` takes `&mut
self`, so this isn't a case of picking the wrong combinator, and
[arrow-rs#8462](https://github.com/apache/arrow-rs/pull/8462) is the actual
unblock. The DataFusion reference is fair, and since the option is off by
default, a user who opts in is accepting the current cost rather than silently
paying it.
The thing that would make me comfortable is the doc on
`with_bloom_filter_enabled` being explicit that the reads are serialized per
row group per column, so the cost model is visible at the call site instead of
only in the tracking issue. Right now it says extra I/O per column per row
group, which reads as a volume cost rather than a latency one, and latency is
what dominates on object storage.
##########
crates/iceberg/src/arrow/reader/row_filter.rs:
##########
@@ -1280,4 +1281,462 @@ mod tests {
"positional deletes must be applied correctly even when page
indexes are absent"
);
}
+
+ // Bloom filter pushdown: on-vs-off equivalence
+ // Pushdown must never change results. An encoding bug in the probe shows
up as
+ // rows the bloom filter drops and the row filter keeps, so every case
below
+ // reads the same file twice and compares. Fixtures spread each row group's
+ // values across the whole domain, so min/max statistics cannot prune and
any
+ // reduction in bytes read is attributable to the bloom filter.
+
+ const ROWS_PER_GROUP: i32 = 20_000;
+ const GROUPS: i32 = 3;
+ /// Gap between consecutive values in a row group, so a value can be
absent from
+ /// the file yet still sit inside every row group's min/max range.
+ const STRIDE: i32 = 30;
+
+ /// The `i`th value of row group `group`, spread across the whole domain.
+ fn interleaved(group: i32, i: i32) -> i32 {
+ i * STRIDE + group
+ }
+
+ fn field_with_id(name: &str, ty: DataType, id: i32) -> Field {
+ Field::new(name, ty, false).with_metadata(HashMap::from([(
+ PARQUET_FIELD_ID_META_KEY.to_string(),
+ id.to_string(),
+ )]))
+ }
+
+ /// Writes one row group per batch, optionally with bloom filters.
+ fn write_row_groups(
+ path: &str,
+ arrow_schema: Arc<ArrowSchema>,
+ row_groups: Vec<RecordBatch>,
+ with_bloom_filters: bool,
+ ) {
+ let mut props =
WriterProperties::builder().set_compression(Compression::UNCOMPRESSED);
+ if with_bloom_filters {
+ props = props
+ .set_bloom_filter_enabled(true)
+ .set_bloom_filter_max_ndv(ROWS_PER_GROUP as u64);
+ }
+
+ let file = File::create(path).unwrap();
+ let mut writer = ArrowWriter::try_new(file, arrow_schema,
Some(props.build())).unwrap();
+ for batch in row_groups {
+ writer.write(&batch).unwrap();
+ // Force a row group boundary so each batch is independently
prunable.
+ writer.flush().unwrap();
+ }
+ writer.close().unwrap();
+ }
+
+ async fn read_once(
+ file_path: &str,
+ schema: Arc<Schema>,
+ project_field_ids: Vec<i32>,
+ predicate: Predicate,
+ bloom_enabled: bool,
+ ) -> (Vec<RecordBatch>, u64) {
+ let file_io = FileIO::new_with_fs();
+ let reader = ArrowReaderBuilder::new(file_io, Runtime::current())
+ .with_bloom_filter_enabled(bloom_enabled)
+ // Keep the fixed footer prefetch small so the byte measurement
reflects
+ // row group I/O rather than a constant metadata read.
+ .with_metadata_size_hint(8 * 1024)
+ .build();
+
+ let task = FileScanTask::builder()
+
.with_file_size_in_bytes(std::fs::metadata(file_path).unwrap().len())
+ .with_start(0)
+ .with_length(0)
+ .with_data_file_path(file_path.to_string())
+ .with_data_file_format(DataFileFormat::Parquet)
+ .with_schema(schema.clone())
+ .with_project_field_ids(project_field_ids)
+ .with_predicate(Some(predicate.bind(schema, true).unwrap()))
+ .with_case_sensitive(false)
+ .build()
+ .unwrap();
+
+ let tasks = Box::pin(futures::stream::iter(vec![Ok(task)])) as
FileScanTaskStream;
+ let result = reader.read(tasks).unwrap();
+ let metrics = result.metrics().clone();
+ let batches: Vec<RecordBatch> =
result.stream().try_collect().await.unwrap();
+
+ (batches, metrics.bytes_read())
+ }
+
+ /// Batch boundaries differ when fewer row groups are read, so collapse to
a
+ /// single batch before comparing. `None` means no rows at all.
+ fn collapse(batches: &[RecordBatch]) -> Option<RecordBatch> {
+ let first = batches.first()?;
+ Some(arrow_select::concat::concat_batches(&first.schema(),
batches).unwrap())
+ }
+
+ /// Reads the file with pushdown on and off, asserts the rows are
identical, and
+ /// returns the rows plus (bytes_on, bytes_off).
+ async fn assert_pushdown_agrees(
+ case: &str,
+ file_path: &str,
+ schema: Arc<Schema>,
+ project_field_ids: Vec<i32>,
+ predicate: Predicate,
+ ) -> (Option<RecordBatch>, u64, u64) {
+ let (off, bytes_off) = read_once(
+ file_path,
+ schema.clone(),
+ project_field_ids.clone(),
+ predicate.clone(),
+ false,
+ )
+ .await;
+ let (on, bytes_on) = read_once(file_path, schema, project_field_ids,
predicate, true).await;
+
+ let off = collapse(&off);
+ let on = collapse(&on);
+ assert_eq!(
+ on, off,
+ "{case}: bloom filter pushdown changed the rows returned"
+ );
+
+ (on, bytes_on, bytes_off)
+ }
+
+ fn rows(batch: &Option<RecordBatch>) -> usize {
+ batch.as_ref().map_or(0, |b| b.num_rows())
+ }
+
+ fn value_schema(iceberg_type: Type) -> Arc<Schema> {
+ Arc::new(
+ Schema::builder()
+ .with_schema_id(1)
+ .with_fields(vec![NestedField::required(1, "v",
iceberg_type).into()])
+ .build()
+ .unwrap(),
+ )
+ }
+
+ /// Writes a single-column file, one row group per group index.
+ fn write_value_fixture(
+ path: &str,
+ data_type: DataType,
+ make_group: impl Fn(i32) -> ArrayRef,
+ with_bloom_filters: bool,
+ ) {
+ let arrow_schema = Arc::new(ArrowSchema::new(vec![field_with_id("v",
data_type, 1)]));
+ let row_groups = (0..GROUPS)
+ .map(|g| RecordBatch::try_new(arrow_schema.clone(),
vec![make_group(g)]).unwrap())
+ .collect();
+ write_row_groups(path, arrow_schema, row_groups, with_bloom_filters);
+ }
+
+ /// The core check for one physical encoding: a value that is present must
+ /// survive pushdown, and one that is absent must prune every row group.
The
+ /// first catches a probe that encodes wrongly and drops real rows; the
second
+ /// catches a probe that silently never prunes.
+ async fn assert_prunes_and_agrees(
+ case: &str,
+ file_path: &str,
+ schema: Arc<Schema>,
+ present: Datum,
+ absent: Datum,
+ ) {
+ let (on, bytes_on, bytes_off) = assert_pushdown_agrees(
+ &format!("{case}/present"),
+ file_path,
+ schema.clone(),
+ vec![1],
+ Reference::new("v").equal_to(present),
+ )
+ .await;
+ assert_eq!(rows(&on), 1, "{case}/present: expected one matching row");
+ assert!(
+ bytes_on < bytes_off,
+ "{case}/present: pushdown must skip row groups: {bytes_on} vs
{bytes_off}"
+ );
+
+ let (on, bytes_on, bytes_off) = assert_pushdown_agrees(
+ &format!("{case}/absent"),
+ file_path,
+ schema,
+ vec![1],
+ Reference::new("v").equal_to(absent),
+ )
+ .await;
+ assert_eq!(rows(&on), 0, "{case}/absent: expected no matching rows");
+ assert!(
+ bytes_on * 2 < bytes_off,
+ "{case}/absent: pruning every row group should cut reads sharply: \
+ {bytes_on} vs {bytes_off}"
+ );
+ }
+
+ /// Value present in exactly one row group: `interleaved(1, 51)`.
+ const PRESENT_INT: i32 = 51 * STRIDE + 1;
+ /// Not congruent to any group index mod STRIDE, so absent from the file
while
+ /// still inside every row group's min/max range.
+ const ABSENT_INT: i32 = 7;
+
+ fn int_group(g: i32) -> ArrayRef {
+ Arc::new(Int32Array::from(
+ (0..ROWS_PER_GROUP)
+ .map(|i| interleaved(g, i))
+ .collect::<Vec<_>>(),
+ ))
+ }
+
+ #[tokio::test]
+ async fn test_bloom_pushdown_int32_eq() {
+ let tmp = TempDir::new().unwrap();
+ let path = format!("{}/int32.parquet", tmp.path().to_str().unwrap());
+ write_value_fixture(&path, DataType::Int32, int_group, true);
+
+ assert_prunes_and_agrees(
+ "int32",
+ &path,
+ value_schema(Type::Primitive(PrimitiveType::Int)),
+ Datum::int(PRESENT_INT),
+ Datum::int(ABSENT_INT),
+ )
+ .await;
+ }
+
+ /// `IN` takes a different evaluator path from `eq`: it must keep the row
group
+ /// when any literal might be present, and prune only when all are absent.
+ #[tokio::test]
+ async fn test_bloom_pushdown_int32_in() {
+ let tmp = TempDir::new().unwrap();
+ let path = format!("{}/int32_in.parquet",
tmp.path().to_str().unwrap());
+ write_value_fixture(&path, DataType::Int32, int_group, true);
+ let schema = value_schema(Type::Primitive(PrimitiveType::Int));
+
+ // One present literal keeps its row group; the absent one must not
suppress it.
+ let (on, bytes_on, bytes_off) = assert_pushdown_agrees(
+ "int32_in/mixed",
+ &path,
+ schema.clone(),
+ vec![1],
+ Reference::new("v").is_in([Datum::int(PRESENT_INT),
Datum::int(ABSENT_INT)]),
+ )
+ .await;
+ assert_eq!(rows(&on), 1);
+ assert!(bytes_on < bytes_off, "{bytes_on} vs {bytes_off}");
+
+ // All literals absent: every row group prunes.
+ let (on, bytes_on, bytes_off) = assert_pushdown_agrees(
+ "int32_in/all_absent",
+ &path,
+ schema,
+ vec![1],
+ Reference::new("v").is_in([Datum::int(ABSENT_INT),
Datum::int(ABSENT_INT + 1)]),
+ )
+ .await;
+ assert_eq!(rows(&on), 0);
+ assert!(bytes_on * 2 < bytes_off, "{bytes_on} vs {bytes_off}");
+ }
+
+ #[tokio::test]
+ async fn test_bloom_pushdown_string_eq() {
+ let tmp = TempDir::new().unwrap();
+ let path = format!("{}/string.parquet", tmp.path().to_str().unwrap());
+ write_value_fixture(
+ &path,
+ DataType::Utf8,
+ |g| {
+ Arc::new(StringArray::from(
+ (0..ROWS_PER_GROUP)
+ .map(|i| format!("{:016}", interleaved(g, i)))
+ .collect::<Vec<_>>(),
+ ))
+ },
+ true,
+ );
+
+ assert_prunes_and_agrees(
+ "string",
+ &path,
+ value_schema(Type::Primitive(PrimitiveType::String)),
+ Datum::string(format!("{PRESENT_INT:016}")),
+ Datum::string(format!("{ABSENT_INT:016}")),
+ )
+ .await;
+ }
+
+ /// Decimal precision decides the Parquet physical type, and the probe has
to be
+ /// encoded to match: precision 9 -> INT32, 15 -> INT64, 25 ->
FIXED_LEN_BYTE_ARRAY.
+ /// Each width is a separate arm of `check_in_bloom_filter`.
+ async fn check_decimal_width(name: &str, precision: u32) {
+ const SCALE: u32 = 2;
+ let tmp = TempDir::new().unwrap();
+ let path = format!("{}/{name}.parquet", tmp.path().to_str().unwrap());
+
+ write_value_fixture(
+ &path,
+ DataType::Decimal128(precision as u8, SCALE as i8),
+ |g| {
+ let values: Vec<i128> = (0..ROWS_PER_GROUP)
+ .map(|i| i128::from(interleaved(g, i)))
+ .collect();
+ Arc::new(
+ Decimal128Array::from(values)
+ .with_precision_and_scale(precision as u8, SCALE as i8)
+ .unwrap(),
+ )
+ },
+ true,
+ );
+
+ let datum = |mantissa: i32| {
+ Datum::decimal_with_precision(
+ crate::spec::decimal_utils::decimal_from_i128_with_scale(
+ i128::from(mantissa),
+ SCALE,
+ ),
+ precision,
+ )
+ .unwrap()
+ };
+
+ assert_prunes_and_agrees(
+ name,
+ &path,
+ value_schema(Type::Primitive(PrimitiveType::Decimal {
+ precision,
+ scale: SCALE,
+ })),
+ datum(PRESENT_INT),
+ datum(ABSENT_INT),
+ )
+ .await;
+ }
+
+ #[tokio::test]
+ async fn test_bloom_pushdown_decimal_int32() {
+ check_decimal_width("decimal_int32", 9).await;
+ }
+
+ #[tokio::test]
+ async fn test_bloom_pushdown_decimal_int64() {
+ check_decimal_width("decimal_int64", 15).await;
+ }
+
+ #[tokio::test]
+ async fn test_bloom_pushdown_decimal_fixed_len_byte_array() {
+ check_decimal_width("decimal_flba", 25).await;
+ }
+
+ /// Widening a decimal's precision does not rewrite existing files, so the
file
+ /// keeps its original physical width while the bound literal arrives at
the new
+ /// precision. Deriving the probe encoding from the schema instead of the
file
+ /// would miss every entry the writer inserted and prune matching row
groups.
+ #[tokio::test]
+ async fn test_bloom_pushdown_decimal_widened_precision() {
+ const SCALE: u32 = 2;
+ let tmp = TempDir::new().unwrap();
+ let path = format!("{}/decimal_widened.parquet",
tmp.path().to_str().unwrap());
+
+ // File written at precision 9 -> INT32.
+ write_value_fixture(
+ &path,
+ DataType::Decimal128(9, SCALE as i8),
+ |g| {
+ let values: Vec<i128> = (0..ROWS_PER_GROUP)
+ .map(|i| i128::from(interleaved(g, i)))
+ .collect();
+ Arc::new(
+ Decimal128Array::from(values)
+ .with_precision_and_scale(9, SCALE as i8)
+ .unwrap(),
+ )
+ },
+ true,
+ );
+
+ // Table schema has since widened to precision 30.
+ let schema = value_schema(Type::Primitive(PrimitiveType::Decimal {
+ precision: 30,
+ scale: SCALE,
+ }));
+ let datum = |mantissa: i32| {
+ Datum::decimal_with_precision(
+ crate::spec::decimal_utils::decimal_from_i128_with_scale(
+ i128::from(mantissa),
+ SCALE,
+ ),
+ 30,
+ )
+ .unwrap()
+ };
+
+ assert_prunes_and_agrees(
+ "decimal_widened",
+ &path,
+ schema,
+ datum(PRESENT_INT),
+ datum(ABSENT_INT),
+ )
+ .await;
+ }
+
+ /// `Sbbf` hashes raw IEEE bytes, so it treats `-0.0` and `0.0` as
distinct. The
+ /// row filter's `arrow_ord::cmp::eq` happens to agree today, but for
unrelated
+ /// reasons, so pin that the two stay consistent.
+ #[tokio::test]
+ async fn test_bloom_pushdown_float_signed_zero() {
+ let tmp = TempDir::new().unwrap();
+ let path = format!("{}/float_zero.parquet",
tmp.path().to_str().unwrap());
+
+ // Row group 0 holds only -0.0; row group 1 only +0.0; row group 2
neither.
+ write_value_fixture(
+ &path,
+ DataType::Float32,
+ |g| {
+ let fill = match g {
+ 0 => -0.0f32,
+ 1 => 0.0f32,
+ _ => 7.5f32,
+ };
+ Arc::new(Float32Array::from(vec![fill; ROWS_PER_GROUP as
usize]))
+ },
+ true,
+ );
Review Comment:
Thanks for adding this one. I think the fixture layout keeps it from testing
what it describes, though: row-group statistics prune the interesting group
before the bloom filter is consulted, so the test passes no matter how either
layer treats signed zero.
`row_group_filtering_enabled` defaults to true and no test here turns it
off, so `RowGroupMetricsEvaluator` runs first. Each group is filled with a
single value, so `min == max`, and Iceberg's float comparison is a total order
where `-0.0 < 0.0`. Probing `0.0` puts group 0 (`min = max = -0.0`) out of
range, so it's pruned on statistics alone; probing `-0.0` does the same to
group 1. In both iterations the group holding the *other* zero disappears
before any probe happens.
So the divergence this test is meant to catch cannot make it fail. If the
bloom check started treating `-0.0` and `+0.0` as equal, group 0 is already
gone. If `arrow_ord::cmp::eq` became IEEE-conformant, group 0 is still already
gone, so the extra rows never surface.
For the test to be able to fail, a row group needs to hold one zero but not
the other, and have a min/max range wide enough that statistics keep it. A
group of `-0.0` plus a larger filler value, probed with `+0.0`, would do it:
- statistics: `0.0` falls inside `[-0.0, 5.0]`, so the group survives
- bloom: `+0.0` was never inserted, so the group is pruned and no rows come
back
- pushdown off: `arrow_ord::cmp::eq` also rejects `-0.0`, so no rows come
back either, and the two agree today
- if that kernel ever became IEEE-conformant, the off path would return the
`-0.0` rows while the bloom path still pruned them, and
`assert_pushdown_agrees` would fail
Asserting the row count explicitly would also help, so the test records
which equality semantics is expected rather than only that the two paths match.
--
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.
To unsubscribe, e-mail: [email protected]
For queries about this service, please contact Infrastructure at:
[email protected]
---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]