mbutrovich commented on code in PR #2961:
URL: https://github.com/apache/iceberg-rust/pull/2961#discussion_r3722140834


##########
crates/iceberg/src/arrow/reader/row_filter.rs:
##########
@@ -62,8 +67,131 @@ impl ArrowReader {
         // creates the projection mask for the Arrow predicates.
         let projection_mask = ProjectionMask::leaves(parquet_schema, 
column_indices.clone());
         let predicate_func = visit(&mut converter, predicates)?;
-        let arrow_predicate = ArrowPredicateFn::new(projection_mask, 
predicate_func);
-        Ok(RowFilter::new(vec![Box::new(arrow_predicate)]))
+        Ok(Box::new(ArrowPredicateFn::new(
+            projection_mask,
+            predicate_func,
+        )))
+    }
+
+    /// Builds one Arrow row-filter predicate per equality-delete set. The 
predicate is based
+    /// on a hash-set lookup (see `EqDeleteSet`). It keeps a row unless its 
key tuple is present
+    /// in that set. A row is deleted when it matches any set (the predicates 
are AND-ed by the `RowFilter`).
+    pub(super) fn build_equality_delete_predicates(
+        sets: &[Arc<EqDeleteSet>],
+        parquet_schema: &SchemaDescriptor,
+        arrow_schema: &ArrowSchemaRef,
+        use_position_fallback: bool,
+    ) -> Result<Vec<Box<dyn ArrowPredicate>>> {
+        let field_id_map =
+            Self::resolve_field_id_map(parquet_schema, arrow_schema, 
use_position_fallback)?;
+
+        let mut predicates: Vec<Box<dyn ArrowPredicate>> = Vec::new();
+        for set in sets {
+            if set.is_empty() {
+                continue;
+            }
+
+            // Parquet leaf index for each key column, in `fields` order; a 
column dropped
+            // from this file by schema evolution has no entry.
+            let leaf_indices: Vec<Option<usize>> = set
+                .fields
+                .iter()
+                .map(|(_, id, _)| field_id_map.get(id).copied())
+                .collect();
+
+            let mut column_indices: Vec<usize> = 
leaf_indices.iter().flatten().copied().collect();
+            column_indices.sort_unstable();
+            column_indices.dedup();
+            let projection_mask = ProjectionMask::leaves(parquet_schema, 
column_indices.clone());
+
+            // Position of each key column within the projected batch 
(parquet-rs presents the
+            // masked leaves in ascending leaf-index order).
+            let batch_positions: Vec<Option<usize>> = leaf_indices
+                .iter()
+                .map(|leaf| leaf.and_then(|idx| 
column_indices.binary_search(&idx).ok()))
+                .collect();
+
+            let target_types: Vec<Type> = set.fields.iter().map(|(_, _, ty)| 
ty.clone()).collect();
+            let num_cols = set.fields.len();
+            let set = set.clone();
+
+            let predicate_func =
+                move |batch: RecordBatch| -> std::result::Result<BooleanArray, 
ArrowError> {
+                    let num_rows = batch.num_rows();
+
+                    // Change each key column into `Datum`s once, promoting to 
the
+                    // table type so the keys match the parsed delete keys 
under schema
+                    // evolution. A column absent from this file reads as 
all-null.
+                    let mut columns: Vec<Vec<Option<Datum>>> = 
Vec::with_capacity(num_cols);
+                    for (i, target_type) in target_types.iter().enumerate() {
+                        let Some(pos) = batch_positions[i] else {
+                            columns.push(vec![None; num_rows]);
+                            continue;
+                        };

Review Comment:
   When an equality-delete key column is absent from a data file's parquet 
schema (schema evolution: the column was added after this file was written), 
the probe treats it as null for every row: `columns.push(vec![None; num_rows]); 
continue;`. Per spec.md:898, "the column value is read for older data files 
using normal projection rules" for exactly this case, and iceberg-rust already 
has a mechanism for "normal projection rules" for a missing column: 
`RecordBatchTransformer` resolves `initial_default` (see 
`record_batch_transformer.rs`, "Rule #3"), used for the data file's own output 
columns at pipeline.rs:494. The eq-delete probe runs earlier, inside the 
`RowFilter`, on raw decoded batches, before `RecordBatchTransformer` ever sees 
them, so it never consults `initial_default` and always substitutes null 
instead. Java's `DeleteFilter` avoids this by construction: it compares against 
`record`, which is built by the regular row reader against `requiredSchema` 
(`DeleteFilter.java:99-101
 , 202-209`), i.e. defaults are already resolved before the delete-set lookup 
runs at all. Concretely: a required column added later with a non-null 
`initial_default`, later used as an equality-delete key, will never delete 
matching rows in data files written before that column existed, because every 
row in those files probes as null instead of the default value. Low likelihood 
in practice, but it's a silent, spec-observable divergence, not a crash, so 
worth a test either way.



##########
crates/iceberg/src/arrow/delete_filter.rs:
##########
@@ -163,68 +162,74 @@ impl DeleteFilter {
         }
     }
 
-    /// Retrieve the equality delete predicate for a given eq delete file path
-    pub(crate) async fn get_equality_delete_predicate_for_delete_file_path(
+    /// Retrieve the equality delete set for a given eq delete file path
+    pub(crate) async fn get_equality_delete_set_for_delete_file_path(
         &self,
         file_path: &str,
-    ) -> Option<Predicate> {
+    ) -> Option<Arc<EqDeleteSet>> {
         let notifier = {
             match self.state.read().unwrap().equality_deletes.get(file_path) {
                 None => return None,
                 Some(EqDelState::Loading(notifier)) => notifier.clone(),
-                Some(EqDelState::Loaded(predicate)) => {
-                    return Some(predicate.clone());
+                Some(EqDelState::Loaded(set)) => {
+                    return Some(set.clone());
                 }
             }
         };
 
         notifier.notified().await;
 
         match self.state.read().unwrap().equality_deletes.get(file_path) {
-            Some(EqDelState::Loaded(predicate)) => Some(predicate.clone()),
+            Some(EqDelState::Loaded(set)) => Some(set.clone()),
             _ => unreachable!("Cannot be any other state than loaded"),
         }
     }
 
-    /// Builds eq delete predicate for the provided task.
-    pub(crate) async fn build_equality_delete_predicate(
+    /// Builds the equality-delete sets applicable to the given task, one per 
distinct
+    /// equality-column layout.
+    pub(crate) async fn build_equality_delete_sets(
         &self,
         file_scan_task: &FileScanTask,
-    ) -> Result<Option<BoundPredicate>> {
-        // * Filter the task's deletes into just the Equality deletes
-        // * Retrieve the unbound predicate for each from 
self.state.equality_deletes
-        // * Logical-AND them all together to get a single combined `Predicate`
-        // * Bind the predicate to the task's schema to get a `BoundPredicate`
-
-        let mut combined_predicate = AlwaysTrue;
+    ) -> Result<Vec<Arc<EqDeleteSet>>> {
+        let mut groups: HashMap<Vec<i32>, Vec<Arc<EqDeleteSet>>> = 
HashMap::new();
         for delete in &file_scan_task.deletes {
             if !is_equality_delete(delete) {
                 continue;
             }
 
-            let Some(predicate) = self
-                
.get_equality_delete_predicate_for_delete_file_path(&delete.file_path)
+            let Some(set) = self
+                
.get_equality_delete_set_for_delete_file_path(&delete.file_path)
                 .await
             else {
                 return Err(Error::new(
                     ErrorKind::Unexpected,
                     format!(
-                        "Missing predicate for equality delete file '{}'",
+                        "Missing equality delete set for delete file '{}'",
                         delete.file_path
                     ),
                 ));
             };
 
-            combined_predicate = combined_predicate.and(predicate);
+            let layout = set.fields.iter().map(|(_, id, _)| *id).collect();

Review Comment:
   Grouping key is `set.fields.iter().map(|(_, id, _)| *id).collect()`, field 
id only, no type. In isolation this looks like it could let two type-mismatched 
equality-delete sets reach `union()` and hard-fail the read. It can't in 
practice: `CachingDeleteFileLoader` evolves every equality-delete file's batch 
stream against `task.schema` before parsing it into an `EqDeleteSet` 
(`caching_delete_file_loader.rs:333-343`, via 
`BasicDeleteFileLoader::evolve_schema` / `RecordBatchTransformer`, which casts 
present-but-differently-typed columns, `record_batch_transformer.rs:810`). So 
every set sharing the same scan's `task.schema` already carries the same 
recorded type for the same field id by construction, and grouping by id only 
mirrors Java's own grouping (`Sets.newHashSet(delete.equalityFieldIds())`, 
`DeleteFilter.java:194`), which also doesn't carry type in the key, for the 
same reason: Java projects every delete file into `requiredSchema` up front 
instead of comparing delete-file-record
 ed types against each other. Given that, the `union()` type check and its 
comment are effectively dead code / defense-in-depth for an invariant that 
already holds structurally, not the live bug I'd have guessed from reading this 
function alone. Worth a comment saying why the invariant holds (ties to 
`evolve_schema` in the other file) instead of "should a change break this," 
since the current comment reads as a guess rather than a traced guarantee. Not 
worth blocking on.



##########
crates/iceberg/src/arrow/reader/row_filter.rs:
##########
@@ -62,8 +67,131 @@ impl ArrowReader {
         // creates the projection mask for the Arrow predicates.
         let projection_mask = ProjectionMask::leaves(parquet_schema, 
column_indices.clone());
         let predicate_func = visit(&mut converter, predicates)?;
-        let arrow_predicate = ArrowPredicateFn::new(projection_mask, 
predicate_func);
-        Ok(RowFilter::new(vec![Box::new(arrow_predicate)]))
+        Ok(Box::new(ArrowPredicateFn::new(
+            projection_mask,
+            predicate_func,
+        )))
+    }
+
+    /// Builds one Arrow row-filter predicate per equality-delete set. The 
predicate is based
+    /// on a hash-set lookup (see `EqDeleteSet`). It keeps a row unless its 
key tuple is present
+    /// in that set. A row is deleted when it matches any set (the predicates 
are AND-ed by the `RowFilter`).
+    pub(super) fn build_equality_delete_predicates(
+        sets: &[Arc<EqDeleteSet>],
+        parquet_schema: &SchemaDescriptor,
+        arrow_schema: &ArrowSchemaRef,
+        use_position_fallback: bool,
+    ) -> Result<Vec<Box<dyn ArrowPredicate>>> {
+        let field_id_map =
+            Self::resolve_field_id_map(parquet_schema, arrow_schema, 
use_position_fallback)?;
+
+        let mut predicates: Vec<Box<dyn ArrowPredicate>> = Vec::new();
+        for set in sets {
+            if set.is_empty() {
+                continue;
+            }
+
+            // Parquet leaf index for each key column, in `fields` order; a 
column dropped
+            // from this file by schema evolution has no entry.
+            let leaf_indices: Vec<Option<usize>> = set
+                .fields
+                .iter()
+                .map(|(_, id, _)| field_id_map.get(id).copied())
+                .collect();
+
+            let mut column_indices: Vec<usize> = 
leaf_indices.iter().flatten().copied().collect();
+            column_indices.sort_unstable();
+            column_indices.dedup();
+            let projection_mask = ProjectionMask::leaves(parquet_schema, 
column_indices.clone());
+
+            // Position of each key column within the projected batch 
(parquet-rs presents the
+            // masked leaves in ascending leaf-index order).
+            let batch_positions: Vec<Option<usize>> = leaf_indices
+                .iter()
+                .map(|leaf| leaf.and_then(|idx| 
column_indices.binary_search(&idx).ok()))
+                .collect();
+
+            let target_types: Vec<Type> = set.fields.iter().map(|(_, _, ty)| 
ty.clone()).collect();
+            let num_cols = set.fields.len();
+            let set = set.clone();
+
+            let predicate_func =
+                move |batch: RecordBatch| -> std::result::Result<BooleanArray, 
ArrowError> {
+                    let num_rows = batch.num_rows();
+
+                    // Change each key column into `Datum`s once, promoting to 
the
+                    // table type so the keys match the parsed delete keys 
under schema
+                    // evolution. A column absent from this file reads as 
all-null.
+                    let mut columns: Vec<Vec<Option<Datum>>> = 
Vec::with_capacity(num_cols);
+                    for (i, target_type) in target_types.iter().enumerate() {
+                        let Some(pos) = batch_positions[i] else {
+                            columns.push(vec![None; num_rows]);
+                            continue;
+                        };
+                        let array = batch.column(pos);
+                        let source_type = arrow_type_to_type(array.data_type())
+                            .map_err(|e| 
ArrowError::ComputeError(e.to_string()))?;
+                        let source_primitive = source_type
+                            .as_primitive_type()
+                            .ok_or_else(|| {
+                                ArrowError::ComputeError(
+                                    "equality delete key column is not a 
primitive type"
+                                        .to_string(),
+                                )
+                            })?
+                            .clone();
+                        let needs_promotion = source_type != *target_type;
+                        let literals = arrow_primitive_to_literal(array, 
&source_type)
+                            .map_err(|e| 
ArrowError::ComputeError(e.to_string()))?;
+
+                        let mut column = Vec::with_capacity(num_rows);
+                        for literal in literals {
+                            let datum = match literal {
+                                Some(literal) => {
+                                    let primitive =
+                                        
literal.as_primitive_literal().ok_or_else(|| {
+                                            ArrowError::ComputeError(
+                                                "failed to convert to 
primitive literal"
+                                                    .to_string(),
+                                            )
+                                        })?;
+                                    let datum = 
Datum::new(source_primitive.clone(), primitive);
+                                    let datum = if needs_promotion {
+                                        datum
+                                            .to(target_type)
+                                            .map_err(|e| 
ArrowError::ComputeError(e.to_string()))?
+                                    } else {
+                                        datum
+                                    };
+                                    Some(datum)
+                                }
+                                None => None,
+                            };
+                            column.push(datum);
+                        }
+                        columns.push(column);
+                    }
+
+                    // One hash lookup per row.
+                    let mut keep = Vec::with_capacity(num_rows);
+                    let mut probe = EqDeleteKey(vec![None; num_cols]);
+                    for row in 0..num_rows {
+                        for (i, column) in columns.iter_mut().enumerate() {
+                            // we can `take` because each cell is probed once.
+                            probe.0[i] = std::mem::take(&mut column[row]);
+                        }
+                        keep.push(!set.keys.contains(&probe));
+                    }
+                    Ok(BooleanArray::from(keep))
+                };

Review Comment:
   Per batch, per key column: `arrow_primitive_to_literal` (row_filter.rs:144) 
builds an owned `Vec<Option<Literal>>` for the whole column, then 
row_filter.rs:148-171 walks it again to build a `Vec<Option<Datum>>`. Two full 
passes and, for string/binary columns, two rounds of per-cell heap ownership, 
on the exact hot path this PR is trying to make cheap. Separately, 
row_filter.rs:158 does `source_primitive.clone()` inside the per-row loop even 
though `source_primitive` is fixed per column (computed once at 
row_filter.rs:134-142); cheap since `PrimitiveType` doesn't allocate, but still 
pointless per-row work. `caching_delete_file_loader.rs:541-556` has the same 
double-pass/box-per-cell pattern already, unmodified by this PR, but it runs 
once per delete-file load; this PR adds a second copy of it on the probe side, 
which runs once per batch per applicable data file, i.e. far more often. 
`arrow::row::RowConverter` (crate `arrow-row`, already in Cargo.lock at 59.1.0, 
not yet a direct dep
  of `crates/iceberg`) builds comparable/hashable rows straight from Arrow 
arrays without this boxing, and is what DataFusion uses for multi-column 
join/group keys. For the common single-column case, DataFusion's `InListExpr` 
static filters 
(`datafusion/physical-expr/src/expressions/in_list/{primitive_filter,static_filter}.rs`)
 use a native-typed `hashbrown`/`ahash` set (`datafusion_common::HashSet`, see 
`datafusion/common/src/lib.rs:119`) instead of boxed values in 
`std::collections::HashSet` with the default SipHash, which is what 
`EqDeleteSet::keys` uses here. Worth noting Java's generic `DeleteFilter` 
doesn't push equality-delete filtering into the file decoder at all, it applies 
a row-by-row `Predicate<T>` after full materialization; pushing it into 
parquet-rs's `RowFilter` the way this PR does is a real advantage over that, so 
this finding is about the representation used inside the predicate, not the 
decision to use a `RowFilter` predicate.



##########
crates/iceberg/src/arrow/reader/row_filter.rs:
##########
@@ -62,8 +67,131 @@ impl ArrowReader {
         // creates the projection mask for the Arrow predicates.
         let projection_mask = ProjectionMask::leaves(parquet_schema, 
column_indices.clone());
         let predicate_func = visit(&mut converter, predicates)?;
-        let arrow_predicate = ArrowPredicateFn::new(projection_mask, 
predicate_func);
-        Ok(RowFilter::new(vec![Box::new(arrow_predicate)]))
+        Ok(Box::new(ArrowPredicateFn::new(
+            projection_mask,
+            predicate_func,
+        )))
+    }
+
+    /// Builds one Arrow row-filter predicate per equality-delete set. The 
predicate is based
+    /// on a hash-set lookup (see `EqDeleteSet`). It keeps a row unless its 
key tuple is present
+    /// in that set. A row is deleted when it matches any set (the predicates 
are AND-ed by the `RowFilter`).
+    pub(super) fn build_equality_delete_predicates(
+        sets: &[Arc<EqDeleteSet>],
+        parquet_schema: &SchemaDescriptor,
+        arrow_schema: &ArrowSchemaRef,
+        use_position_fallback: bool,
+    ) -> Result<Vec<Box<dyn ArrowPredicate>>> {
+        let field_id_map =
+            Self::resolve_field_id_map(parquet_schema, arrow_schema, 
use_position_fallback)?;

Review Comment:
   `resolve_field_id_map` recomputes the parquet-schema-to-field-id map 
whenever `eq_delete_sets` is non-empty, even though pipeline.rs already 
computed an equivalent map for the scan predicate a few lines earlier via 
`ArrowReader::build_field_id_set_and_map` (projection.rs:49-62, which itself 
calls `resolve_field_id_map`). Same schema both times; minor duplicated 
per-file work, not per-row, so low severity, but easy to thread through instead.



##########
crates/iceberg/src/arrow/reader/pipeline.rs:
##########
@@ -400,13 +395,12 @@ impl FileScanTaskReader {
                 use_position_fallback,
             )?;
 
-            let row_filter = ArrowReader::get_row_filter(
+            row_filter_predicates.push(ArrowReader::build_scan_predicate(

Review Comment:
   Also line 440.
   
   Scan predicate is always pushed before the eq-delete predicates, 
unconditionally. parquet-rs applies `RowFilter` predicates in list order, 
threading a shrinking `RowSelection` between them (arrow-rs 
`parquet/src/arrow/arrow_reader/filter.rs:139-149`), so a cheap/selective 
predicate run first prunes work for everything after it. DataFusion's 
`ParquetSource` reorders predicates by estimated column byte-size before 
building the `RowFilter` for exactly this reason 
(`datafusion/datasource-parquet/src/row_filter.rs:33-52`). A hash-probe over 
one or two narrow key columns is often cheaper and more selective than an 
arbitrary scan predicate; this PR never reorders to take advantage of that.



##########
crates/iceberg/src/arrow/delete_filter.rs:
##########
@@ -163,68 +162,74 @@ impl DeleteFilter {
         }
     }
 
-    /// Retrieve the equality delete predicate for a given eq delete file path
-    pub(crate) async fn get_equality_delete_predicate_for_delete_file_path(
+    /// Retrieve the equality delete set for a given eq delete file path
+    pub(crate) async fn get_equality_delete_set_for_delete_file_path(
         &self,
         file_path: &str,
-    ) -> Option<Predicate> {
+    ) -> Option<Arc<EqDeleteSet>> {
         let notifier = {
             match self.state.read().unwrap().equality_deletes.get(file_path) {
                 None => return None,
                 Some(EqDelState::Loading(notifier)) => notifier.clone(),
-                Some(EqDelState::Loaded(predicate)) => {
-                    return Some(predicate.clone());
+                Some(EqDelState::Loaded(set)) => {
+                    return Some(set.clone());
                 }
             }
         };
 
         notifier.notified().await;
 
         match self.state.read().unwrap().equality_deletes.get(file_path) {
-            Some(EqDelState::Loaded(predicate)) => Some(predicate.clone()),
+            Some(EqDelState::Loaded(set)) => Some(set.clone()),
             _ => unreachable!("Cannot be any other state than loaded"),
         }
     }
 
-    /// Builds eq delete predicate for the provided task.
-    pub(crate) async fn build_equality_delete_predicate(
+    /// Builds the equality-delete sets applicable to the given task, one per 
distinct
+    /// equality-column layout.
+    pub(crate) async fn build_equality_delete_sets(
         &self,
         file_scan_task: &FileScanTask,
-    ) -> Result<Option<BoundPredicate>> {
-        // * Filter the task's deletes into just the Equality deletes
-        // * Retrieve the unbound predicate for each from 
self.state.equality_deletes
-        // * Logical-AND them all together to get a single combined `Predicate`
-        // * Bind the predicate to the task's schema to get a `BoundPredicate`
-
-        let mut combined_predicate = AlwaysTrue;
+    ) -> Result<Vec<Arc<EqDeleteSet>>> {
+        let mut groups: HashMap<Vec<i32>, Vec<Arc<EqDeleteSet>>> = 
HashMap::new();
         for delete in &file_scan_task.deletes {
             if !is_equality_delete(delete) {
                 continue;
             }
 
-            let Some(predicate) = self
-                
.get_equality_delete_predicate_for_delete_file_path(&delete.file_path)
+            let Some(set) = self
+                
.get_equality_delete_set_for_delete_file_path(&delete.file_path)
                 .await
             else {
                 return Err(Error::new(
                     ErrorKind::Unexpected,
                     format!(
-                        "Missing predicate for equality delete file '{}'",
+                        "Missing equality delete set for delete file '{}'",
                         delete.file_path
                     ),
                 ));
             };
 
-            combined_predicate = combined_predicate.and(predicate);
+            let layout = set.fields.iter().map(|(_, id, _)| *id).collect();
+            groups.entry(layout).or_default().push(set);
         }
 
-        if combined_predicate == AlwaysTrue {
-            return Ok(None);
+        let mut result = Vec::with_capacity(groups.len());
+        for mut sets in groups.into_values() {
+            if sets.len() == 1 {
+                result.push(sets.pop().unwrap());
+            } else {
+                let mut combined = (*sets[0]).clone();
+                for other in &sets[1..] {
+                    // `union` checks if `other`s' layout matches `combined`,
+                    // which is currently always the case. This fails should a 
change
+                    // break this current invariant.
+                    combined.union(other)?;
+                }
+                result.push(Arc::new(combined));
+            }
         }

Review Comment:
   `build_equality_delete_sets` runs once per data-file scan task, and whenever 
a data file has more than one delete file sharing a layout, it clones the first 
set's entire `HashSet<EqDeleteKey>` and unions in the rest, from scratch, every 
time. The PR's own benchmark numbers (7,140 and 124,750 "delete refs") are 
exactly the count of (data-file, delete-file) edges this repeats over, so the 
real cost is closer to `O(data_rows + delete_refs)` than the claimed 
`O(data_rows + delete_keys)` once delete files span many data files, which is 
the compaction case motivating the PR. Java doesn't have this cost at all: it 
builds one `StructLikeSet` per equality-id group once per `DeleteFilter` 
(`DeleteFilter.java:191-211`), not per data file. Fix: pass 
`Vec<Arc<EqDeleteSet>>` per layout to the row filter and probe against all of 
them (row is deleted if it matches any), instead of merging into one owned set 
per task. `Arc::clone` is a refcount bump; the `HashSet` clone is not.



-- 
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]

Reply via email to