This is an automated email from the ASF dual-hosted git repository.

github-merge-queue[bot] pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/iceberg-rust.git


The following commit(s) were added to refs/heads/main by this push:
     new 615af68da fix(expr): decode BYTE_ARRAY page-index bounds by field type 
(#3247)
615af68da is described below

commit 615af68da85f2cc6e5139aa180c939214de91d32
Author: Anoop Johnson <[email protected]>
AuthorDate: Sat Sep 26 09:28:36 2026 +0000

    fix(expr): decode BYTE_ARRAY page-index bounds by field type (#3247)
    
    * fix(expr): decode BYTE_ARRAY page-index bounds by field type
    
    The page index evaluator decoded every BYTE_ARRAY min/max bound as a
    UTF-8 string via String::from_utf8(..).unwrap(), panicking during scan
    pruning whenever a binary (or byte-encoded decimal) column carried a
    predicate. Convert the bound according to the field's primitive type
    instead: string, binary, or decimal.
    
    * PR feedback
    
    * Round2 feedback
    
    * Fix trace
---
 .../src/expr/visitors/page_index_evaluator.rs      | 376 +++++++++++++++++++--
 1 file changed, 343 insertions(+), 33 deletions(-)

diff --git a/crates/iceberg/src/expr/visitors/page_index_evaluator.rs 
b/crates/iceberg/src/expr/visitors/page_index_evaluator.rs
index bd901eda5..37d9c6a23 100644
--- a/crates/iceberg/src/expr/visitors/page_index_evaluator.rs
+++ b/crates/iceberg/src/expr/visitors/page_index_evaluator.rs
@@ -191,6 +191,7 @@ impl<'a> PageIndexEvaluator<'a> {
         };
 
         let Some(page_filter) = Self::apply_predicate_to_column_index(
+            field_id,
             predicate,
             field_type,
             column_index,
@@ -238,6 +239,7 @@ impl<'a> PageIndexEvaluator<'a> {
     }
 
     fn apply_predicate_to_column_index<F>(
+        field_id: i32,
         predicate: F,
         field_type: &PrimitiveType,
         column_index: &ColumnIndexMetaData,
@@ -339,46 +341,96 @@ impl<'a> PageIndexEvaluator<'a> {
                     )
                 })
                 .collect(),
-            ColumnIndexMetaData::BYTE_ARRAY(idx) => idx
-                .min_values_iter()
-                .zip(idx.max_values_iter())
-                .enumerate()
-                .zip(row_counts.iter())
-                .map(|((i, (min, max)), &row_count)| {
-                    predicate(
-                        min.map(|val| {
-                            Datum::new(
-                                field_type.clone(),
-                                
PrimitiveLiteral::String(String::from_utf8(val.to_vec()).unwrap()),
-                            )
-                        }),
-                        max.map(|val| {
-                            Datum::new(
-                                field_type.clone(),
-                                
PrimitiveLiteral::String(String::from_utf8(val.to_vec()).unwrap()),
-                            )
-                        }),
+            ColumnIndexMetaData::BYTE_ARRAY(idx) => {
+                // Parquet stores Iceberg string and binary bounds as 
BYTE_ARRAY.
+                // Any other field type on a BYTE_ARRAY column (e.g. a non-spec
+                // BYTE_ARRAY decimal) can't be decoded safely, so skip page
+                // pruning for it rather than risk pruning pages that match.
+                if !matches!(field_type, PrimitiveType::String | 
PrimitiveType::Binary) {
+                    tracing::debug!(
+                        field_id,
+                        %field_type,
+                        "Skipping page-index pruning: BYTE_ARRAY column index 
on a non-string/binary field"
+                    );
+                    return Ok(None);
+                }
+
+                let mut page_filter = Vec::with_capacity(row_counts.len());
+                for ((i, (min, max)), &row_count) in idx
+                    .min_values_iter()
+                    .zip(idx.max_values_iter())
+                    .enumerate()
+                    .zip(row_counts.iter())
+                {
+                    // A bound that won't decode (e.g. a min/max stat truncated
+                    // mid-UTF-8-sequence) means this column's page index can't
+                    // be trusted, so skip pruning for the whole column rather
+                    // than abort the scan.
+                    let (min, max) = match (
+                        min.map(|val| 
Self::byte_array_bound_to_datum(field_type, val))
+                            .transpose(),
+                        max.map(|val| 
Self::byte_array_bound_to_datum(field_type, val))
+                            .transpose(),
+                    ) {
+                        (Ok(min), Ok(max)) => (min, max),
+                        (Err(err), _) | (_, Err(err)) => {
+                            tracing::debug!(
+                                field_id,
+                                %field_type,
+                                page_index = i,
+                                %err,
+                                "Skipping page-index pruning: undecodable 
BYTE_ARRAY page bound"
+                            );
+                            return Ok(None);
+                        }
+                    };
+
+                    page_filter.push(predicate(
+                        min,
+                        max,
                         PageNullCount::from_row_and_null_counts(row_count, 
idx.null_count(i)),
-                    )
-                })
-                .collect(),
-            ColumnIndexMetaData::FIXED_LEN_BYTE_ARRAY(_) => {
-                return Err(Error::new(
-                    ErrorKind::FeatureUnsupported,
-                    "unsupported 'FIXED_LEN_BYTE_ARRAY' index type in 
column_index",
-                ));
+                    )?);
+                }
+
+                Ok(page_filter)
             }
-            ColumnIndexMetaData::INT96(_) => {
-                return Err(Error::new(
-                    ErrorKind::FeatureUnsupported,
-                    "unsupported 'INT96' index type in column_index",
-                ));
+            // Column index types we can't interpret: skip page pruning rather
+            // than abort the scan. Row-group filtering and the Arrow row 
filter
+            // still apply the predicate.
+            ColumnIndexMetaData::FIXED_LEN_BYTE_ARRAY(_) | 
ColumnIndexMetaData::INT96(_) => {
+                tracing::debug!(
+                    field_id,
+                    %field_type,
+                    "Skipping page-index pruning: unsupported 
FIXED_LEN_BYTE_ARRAY or INT96 column index"
+                );
+                return Ok(None);
             }
         };
 
         Ok(Some(result?))
     }
 
+    /// Converts a `BYTE_ARRAY` page bound into a [`Datum`] according to the
+    /// field's primitive type. Parquet stores Iceberg `string` and `binary`
+    /// bounds as `BYTE_ARRAY`.
+    fn byte_array_bound_to_datum(field_type: &PrimitiveType, bytes: &[u8]) -> 
Result<Datum> {
+        match field_type {
+            PrimitiveType::String => {
+                let value = std::str::from_utf8(bytes).map_err(|err| {
+                    Error::new(ErrorKind::DataInvalid, "Invalid UTF-8 in 
string page bound")
+                        .with_source(err)
+                })?;
+                Ok(Datum::string(value))
+            }
+            PrimitiveType::Binary => Ok(Datum::binary(bytes.to_vec())),
+            // Defensive: production callers only invoke this for 
String/Binary.
+            _ => Err(Error::new(
+                ErrorKind::Unexpected,
+                format!("Unsupported primitive type for BYTE_ARRAY page bound: 
{field_type}"),
+            )),
+        }
+    }
+
     fn visit_inequality(
         &mut self,
         reference: &BoundReference,
@@ -779,7 +831,9 @@ mod tests {
     use std::collections::HashMap;
     use std::sync::Arc;
 
-    use arrow_array::{ArrayRef, Float32Array, RecordBatch, StringArray};
+    use arrow_array::{
+        ArrayRef, FixedSizeBinaryArray, Float32Array, LargeBinaryArray, 
RecordBatch, StringArray,
+    };
     use arrow_schema::{DataType, Field, Schema as ArrowSchema};
     use parquet::arrow::ArrowWriter;
     use parquet::arrow::arrow_reader::{
@@ -896,6 +950,92 @@ mod tests {
         Ok((metadata, temp_file))
     }
 
+    /// Creates a single-column parquet file whose `col_binary` page bounds 
hold
+    /// non-UTF-8 bytes, backing the binary path through `BYTE_ARRAY` column
+    /// indexes. Page 0 is `[0x01]`, page 1 is `[0xff, 0x00]`.
+    fn create_binary_parquet_file() -> Result<(Arc<ParquetMetaData>, 
NamedTempFile)> {
+        let arrow_schema = Arc::new(ArrowSchema::new(vec![Field::new(
+            "col_binary",
+            DataType::LargeBinary,
+            true,
+        )]));
+
+        let temp_file = NamedTempFile::new().unwrap();
+        let file = temp_file.reopen().unwrap();
+
+        let props = WriterProperties::builder()
+            .set_data_page_row_count_limit(1024)
+            .set_write_batch_size(512)
+            .build();
+
+        let mut writer = ArrowWriter::try_new(file, arrow_schema.clone(), 
Some(props)).unwrap();
+
+        // Each page holds 1024 rows of a single value, one page per byte 
pattern.
+        let pages: [&[u8]; 2] = [&[0x01], &[0xff, 0x00]];
+
+        for value in pages {
+            let array = 
Arc::new(LargeBinaryArray::from_iter_values(std::iter::repeat_n(
+                value, 1024,
+            ))) as ArrayRef;
+            let batch = RecordBatch::try_new(arrow_schema.clone(), 
vec![array]).unwrap();
+            for i in 0..batch.num_rows() {
+                writer.write(&batch.slice(i, 1)).unwrap();
+            }
+        }
+
+        writer.close().unwrap();
+
+        let file = temp_file.reopen().unwrap();
+        let options = 
ArrowReaderOptions::new().with_page_index_policy(PageIndexPolicy::Required);
+        let reader = 
ParquetRecordBatchReaderBuilder::try_new_with_options(file, options).unwrap();
+        let metadata = reader.metadata().clone();
+
+        Ok((metadata, temp_file))
+    }
+
+    /// Creates a single-column parquet file with a `FixedSizeBinary(2)` 
column,
+    /// producing `FIXED_LEN_BYTE_ARRAY` page bounds. Page 0 is `[0x01, 0x01]`,
+    /// page 1 is `[0x02, 0x02]`.
+    fn create_fixed_len_byte_array_parquet_file() -> 
Result<(Arc<ParquetMetaData>, NamedTempFile)> {
+        let arrow_schema = Arc::new(ArrowSchema::new(vec![Field::new(
+            "col_fixed",
+            DataType::FixedSizeBinary(2),
+            true,
+        )]));
+
+        let temp_file = NamedTempFile::new().unwrap();
+        let file = temp_file.reopen().unwrap();
+
+        let props = WriterProperties::builder()
+            .set_data_page_row_count_limit(1024)
+            .set_write_batch_size(512)
+            .build();
+
+        let mut writer = ArrowWriter::try_new(file, arrow_schema.clone(), 
Some(props)).unwrap();
+
+        // Each page holds 1024 rows of a single value, one page per byte 
pattern.
+        let pages: [&[u8]; 2] = [&[0x01, 0x01], &[0x02, 0x02]];
+
+        for value in pages {
+            let array = Arc::new(
+                FixedSizeBinaryArray::try_from_iter(std::iter::repeat_n(value, 
1024)).unwrap(),
+            ) as ArrayRef;
+            let batch = RecordBatch::try_new(arrow_schema.clone(), 
vec![array]).unwrap();
+            for i in 0..batch.num_rows() {
+                writer.write(&batch.slice(i, 1)).unwrap();
+            }
+        }
+
+        writer.close().unwrap();
+
+        let file = temp_file.reopen().unwrap();
+        let options = 
ArrowReaderOptions::new().with_page_index_policy(PageIndexPolicy::Required);
+        let reader = 
ParquetRecordBatchReaderBuilder::try_new_with_options(file, options).unwrap();
+        let metadata = reader.metadata().clone();
+
+        Ok((metadata, temp_file))
+    }
+
     /// Get the test metadata components for testing
     fn get_test_metadata(
         metadata: &ParquetMetaData,
@@ -1185,6 +1325,176 @@ mod tests {
         Ok(())
     }
 
+    #[test]
+    fn eval_inequality_prunes_binary_pages_with_non_utf8_bounds() -> 
Result<()> {
+        let (metadata, _temp_file) = create_binary_parquet_file()?;
+        let (column_index, offset_index, row_group_metadata) = 
get_test_metadata(&metadata);
+
+        let iceberg_schema = Arc::new(
+            Schema::builder()
+                .with_fields([Arc::new(NestedField::new(
+                    1,
+                    "col_binary",
+                    Type::Primitive(PrimitiveType::Binary),
+                    true,
+                ))])
+                .build()?,
+        );
+        let field_id_map = HashMap::from_iter([(1, 0)]);
+
+        // Page 0 bounds are [0x01], page 1 bounds are [0xff, 0x00]; only page 
1
+        // exceeds [0x80]. Decoding [0xff, 0x00] as UTF-8 would panic.
+        let filter = Reference::new("col_binary")
+            .greater_than(Datum::binary(vec![0x80u8]))
+            .bind(iceberg_schema.clone(), false)?;
+
+        let result = PageIndexEvaluator::eval(
+            &filter,
+            &column_index,
+            &offset_index,
+            row_group_metadata,
+            &field_id_map,
+            iceberg_schema.as_ref(),
+        )?;
+
+        let expected = vec![RowSelector::skip(1024), 
RowSelector::select(1024)];
+
+        assert_eq!(result, expected);
+
+        Ok(())
+    }
+
+    #[test]
+    fn eval_skips_pruning_for_byte_array_decimal_bounds() -> Result<()> {
+        // A non-spec writer can store a decimal column as BYTE_ARRAY. Those
+        // bounds can't be decoded safely (Parquet may truncate them, and
+        // truncation only preserves lexicographic byte order, not decimal
+        // order), so the evaluator skips page pruning rather than prune pages
+        // that might match.
+        let (metadata, _temp_file) = create_binary_parquet_file()?;
+        let (column_index, offset_index, row_group_metadata) = 
get_test_metadata(&metadata);
+
+        let iceberg_schema = Arc::new(
+            Schema::builder()
+                .with_fields([Arc::new(NestedField::new(
+                    1,
+                    "col_decimal",
+                    Type::Primitive(PrimitiveType::Decimal {
+                        precision: 10,
+                        scale: 2,
+                    }),
+                    true,
+                ))])
+                .build()?,
+        );
+        let field_id_map = HashMap::from_iter([(1, 0)]);
+
+        // A predicate that would prune every page if the bounds were decoded.
+        let filter = Reference::new("col_decimal")
+            .greater_than(Datum::decimal_with_precision(
+                
crate::spec::decimal_utils::decimal_from_i128_with_scale(99999, 2),
+                10,
+            )?)
+            .bind(iceberg_schema.clone(), false)?;
+
+        let result = PageIndexEvaluator::eval(
+            &filter,
+            &column_index,
+            &offset_index,
+            row_group_metadata,
+            &field_id_map,
+            iceberg_schema.as_ref(),
+        )?;
+
+        // Both 1024-row pages survive: no page pruning is applied.
+        assert_eq!(result, vec![RowSelector::select(2048)]);
+
+        Ok(())
+    }
+
+    #[test]
+    fn eval_skips_pruning_for_fixed_len_byte_array() -> Result<()> {
+        // FIXED_LEN_BYTE_ARRAY page indexes back spec-conforming fixed, uuid,
+        // and decimal(P > 18) columns. The evaluator can't interpret those
+        // bounds, so it skips page pruning rather than abort the scan.
+        let (metadata, _temp_file) = 
create_fixed_len_byte_array_parquet_file()?;
+        let (column_index, offset_index, row_group_metadata) = 
get_test_metadata(&metadata);
+
+        let iceberg_schema = Arc::new(
+            Schema::builder()
+                .with_fields([Arc::new(NestedField::new(
+                    1,
+                    "col_fixed",
+                    Type::Primitive(PrimitiveType::Fixed(2)),
+                    true,
+                ))])
+                .build()?,
+        );
+        let field_id_map = HashMap::from_iter([(1, 0)]);
+
+        // A predicate that would prune every page if the bounds were decoded.
+        let filter = Reference::new("col_fixed")
+            .greater_than(Datum::fixed(vec![0xffu8, 0xff]))
+            .bind(iceberg_schema.clone(), false)?;
+
+        let result = PageIndexEvaluator::eval(
+            &filter,
+            &column_index,
+            &offset_index,
+            row_group_metadata,
+            &field_id_map,
+            iceberg_schema.as_ref(),
+        )?;
+
+        // All rows survive: no page pruning is applied for 
FIXED_LEN_BYTE_ARRAY.
+        assert_eq!(result, vec![RowSelector::select(2048)]);
+
+        Ok(())
+    }
+
+    #[test]
+    fn eval_skips_pruning_for_non_utf8_string_bound() -> Result<()> {
+        // A writer can truncate a string min/max stat mid-UTF-8-sequence,
+        // leaving a BYTE_ARRAY page bound that isn't valid UTF-8. Decoding it
+        // as a string fails, so the evaluator skips page pruning for the 
column
+        // rather than aborting the whole file read.
+        let (metadata, _temp_file) = create_binary_parquet_file()?;
+        let (column_index, offset_index, row_group_metadata) = 
get_test_metadata(&metadata);
+
+        let iceberg_schema = Arc::new(
+            Schema::builder()
+                .with_fields([Arc::new(NestedField::new(
+                    1,
+                    "col_string",
+                    Type::Primitive(PrimitiveType::String),
+                    true,
+                ))])
+                .build()?,
+        );
+        let field_id_map = HashMap::from_iter([(1, 0)]);
+
+        // Page 1's bound [0xff, 0x00] isn't valid UTF-8. A predicate that 
would
+        // otherwise prune every page must not: the undecodable bound forces a
+        // skip rather than an error.
+        let filter = Reference::new("col_string")
+            .greater_than(Datum::string("zzz"))
+            .bind(iceberg_schema.clone(), false)?;
+
+        let result = PageIndexEvaluator::eval(
+            &filter,
+            &column_index,
+            &offset_index,
+            row_group_metadata,
+            &field_id_map,
+            iceberg_schema.as_ref(),
+        )?;
+
+        // Both pages survive: no page pruning is applied when a bound won't 
decode.
+        assert_eq!(result, vec![RowSelector::select(2048)]);
+
+        Ok(())
+    }
+
     #[test]
     fn eval_starts_with_error_float_col() -> Result<()> {
         let (metadata, _temp_file) = create_test_parquet_file()?;

Reply via email to