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


##########
crates/iceberg/src/arrow/reader/row_filter.rs:
##########
@@ -62,8 +70,18 @@ impl ArrowReader {
         // After collecting required leaf column indices used in the predicate,
         // 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);
+        let mut predicate_func = visit(&mut converter, predicates)?;
+        // Predicates are bound to the table schema, so evaluate them on the 
filter columns
+        // converted to it, as the reader's output is (e.g. a file written 
before an `int` ->
+        // `long` promotion).
+        let mut transformer =
+            RecordBatchTransformerBuilder::new(snapshot_schema, 
&field_ids).build();

Review Comment:
   This moves the type conversion from the literal to the column, which adds 
cost on two paths.
   
   Per batch, a filter column whose file type differs from the table type is 
now cast in full, where `main` cast one scalar literal. That covers promoted 
columns and `large_string` or `large_binary` files (the 0.5 ns per row in the 
description), and it scales with the row count. `timestamptz` columns take this 
path too, because parquet-rs reads UTC-adjusted timestamps with the timezone 
`"UTC"` and the table type uses `"+00:00"`. That cast only relabels the 
timezone and leaves the values alone, so it costs the same for any batch size.
   
   Per file, this transformer builds its plan on the first batch. 
[`generate_batch_transform`](https://github.com/apache/iceberg-rust/blob/0be54f9076c096e13e3737a6016ab277c495222d/crates/iceberg/src/arrow/record_batch_transformer.rs#L798-L800)
 converts the whole snapshot schema to Arrow and maps every top-level field, 
even when the result is a pass-through. The [output 
transformer](https://github.com/apache/iceberg-rust/blob/0be54f9076c096e13e3737a6016ab277c495222d/crates/iceberg/src/arrow/reader/pipeline.rs#L333-L334)
 already does this, so every file read with a predicate now does it twice. The 
cost grows with the table's width, not with the number of filter columns.
   
   The degenerate case is a wide table stored as many small files, such as a 
table with hundreds of columns that is heavily partitioned or bucketed, or 
written by frequent small commits. Every file pays the setup, and there are few 
rows to spread it over. A selective filter on a `large_string` column is the 
per-batch counterpart. Building the plan from only the fields in `field_ids` 
would remove the per-file part, and the output transformer would gain from it 
too. Could the description name these costs either way?



##########
crates/iceberg/src/arrow/reader/row_filter.rs:
##########
@@ -460,6 +478,89 @@ mod tests {
         }
     }
 
+    /// Writes `column` as field 1, reads it back as `iceberg_type` under each 
predicate, and
+    /// checks the values that survive (`None` when no row does).
+    async fn assert_promoted_reads(
+        column: ArrayRef,
+        iceberg_type: PrimitiveType,
+        cases: Vec<(Predicate, Option<ArrayRef>)>,
+    ) {
+        let tmp = TempDir::new().unwrap();
+        let path = format!("{}/column.parquet", tmp.path().to_str().unwrap());
+        let arrow_schema = Arc::new(ArrowSchema::new(vec![field_with_id(
+            "v",
+            column.data_type().clone(),
+            1,
+        )]));
+        let batch = RecordBatch::try_new(arrow_schema.clone(), 
vec![column]).unwrap();
+        write_row_groups(&path, arrow_schema, vec![batch], false);
+
+        let schema = value_schema(Type::Primitive(iceberg_type));
+        let file_size = std::fs::metadata(&path).unwrap().len();
+        let reader = ArrowReaderBuilder::new(FileIO::new_with_fs(), 
Runtime::current()).build();
+        for (predicate, expected) in cases {
+            let task = FileScanTask::builder()
+                .with_file_size_in_bytes(file_size)
+                .with_start(0)
+                .with_length(0)
+                .with_data_file_path(path.clone())
+                .with_data_file_format(DataFileFormat::Parquet)
+                .with_schema(schema.clone())
+                .with_project_field_ids(vec![1])
+                .with_predicate(Some(predicate.bind(schema.clone(), 
true).unwrap()))
+                .with_case_sensitive(true)
+                .build()
+                .unwrap();
+            let tasks = Box::pin(futures::stream::iter(vec![Ok(task)])) as 
FileScanTaskStream;
+            let batches: Vec<RecordBatch> = reader
+                .clone()
+                .read(tasks)
+                .unwrap()
+                .stream()
+                .try_collect()
+                .await
+                .unwrap();
+            let actual = collapse(&batches).map(|batch| 
Arc::clone(batch.column(0)));
+            assert_eq!(actual, expected, "{predicate}");
+        }
+    }
+
+    /// A file written before a type promotion keeps the old physical type, so 
the row
+    /// filter must compare its values promoted to the table type.
+    #[tokio::test]
+    async fn test_predicate_on_promoted_column() {
+        let longs = |values: Vec<i64>| Some(Arc::new(Int64Array::from(values)) 
as ArrayRef);
+        let all = longs(vec![1, 2]);
+        let above = || Datum::long(i64::from(i32::MAX) + 1);
+        let below = || Datum::long(i64::from(i32::MIN) - 1);
+        let v = || Reference::new("v");
+        assert_promoted_reads(
+            Arc::new(Int32Array::from(vec![1, 2])),
+            PrimitiveType::Long,
+            vec![
+                (v().less_than(above()), all.clone()),
+                (v().less_than_or_equal_to(above()), all.clone()),
+                (v().greater_than_or_equal_to(below()), all.clone()),
+                (v().not_equal_to(above()), all),
+                (v().is_in([Datum::long(1), above()]), longs(vec![1])),
+                (v().is_not_in([Datum::long(1), above()]), longs(vec![2])),
+            ],
+        )
+        .await;
+
+        // 0.1f32 promotes to a double that is greater than 0.1, not equal to 
it.
+        let promoted = Arc::new(Float64Array::from(vec![f64::from(0.1f32)])) 
as ArrayRef;
+        assert_promoted_reads(
+            Arc::new(Float32Array::from(vec![0.1])),
+            PrimitiveType::Double,
+            vec![
+                (v().greater_than(Datum::double(0.1)), Some(promoted)),
+                (v().equal_to(Datum::double(0.1)), None),
+            ],
+        )
+        .await;
+    }

Review Comment:
   This change also fixes the v3 [`date` to `timestamp` and `timestamp_ns` 
promotions](https://github.com/apache/iceberg/blob/6275107e38cc87a7d416ef231019ceb51556f196/format/spec.md?plain=1#L395-L403).
 On `main`, a `date` file holding 1970-01-01 and 2024-01-01, read as 
`timestamp` with `v < 2024-01-01T12:00:00`, returns only 1970-01-01. The 
literal is cast down to `Date32` and truncated to 2024-01-01. `timestamp_ns` 
loses the same row. Should we add a case for it here, and mention it in the 
description? For example, after the `double` case:
   
   ```rust
   // 2024-01-01 promotes to midnight, which is before noon on that day.
   let noon = || Datum::timestamp_from_str("2024-01-01T12:00:00").unwrap();
   let midnight = 
Arc::new(TimestampMicrosecondArray::from(vec![1_704_067_200_000_000]));
   assert_promoted_reads(
       Arc::new(Date32Array::from(vec![19723])),
       PrimitiveType::Timestamp,
       vec![(v().less_than(noon()), Some(midnight as ArrayRef))],
   )
   .await;
   ```



##########
crates/iceberg/src/arrow/reader/row_filter.rs:
##########
@@ -460,6 +478,89 @@ mod tests {
         }
     }
 
+    /// Writes `column` as field 1, reads it back as `iceberg_type` under each 
predicate, and
+    /// checks the values that survive (`None` when no row does).
+    async fn assert_promoted_reads(
+        column: ArrayRef,
+        iceberg_type: PrimitiveType,
+        cases: Vec<(Predicate, Option<ArrayRef>)>,
+    ) {
+        let tmp = TempDir::new().unwrap();
+        let path = format!("{}/column.parquet", tmp.path().to_str().unwrap());
+        let arrow_schema = Arc::new(ArrowSchema::new(vec![field_with_id(
+            "v",
+            column.data_type().clone(),
+            1,
+        )]));
+        let batch = RecordBatch::try_new(arrow_schema.clone(), 
vec![column]).unwrap();
+        write_row_groups(&path, arrow_schema, vec![batch], false);
+
+        let schema = value_schema(Type::Primitive(iceberg_type));
+        let file_size = std::fs::metadata(&path).unwrap().len();
+        let reader = ArrowReaderBuilder::new(FileIO::new_with_fs(), 
Runtime::current()).build();
+        for (predicate, expected) in cases {
+            let task = FileScanTask::builder()
+                .with_file_size_in_bytes(file_size)
+                .with_start(0)
+                .with_length(0)
+                .with_data_file_path(path.clone())
+                .with_data_file_format(DataFileFormat::Parquet)
+                .with_schema(schema.clone())
+                .with_project_field_ids(vec![1])
+                .with_predicate(Some(predicate.bind(schema.clone(), 
true).unwrap()))
+                .with_case_sensitive(true)
+                .build()
+                .unwrap();
+            let tasks = Box::pin(futures::stream::iter(vec![Ok(task)])) as 
FileScanTaskStream;
+            let batches: Vec<RecordBatch> = reader
+                .clone()
+                .read(tasks)
+                .unwrap()
+                .stream()
+                .try_collect()
+                .await
+                .unwrap();
+            let actual = collapse(&batches).map(|batch| 
Arc::clone(batch.column(0)));
+            assert_eq!(actual, expected, "{predicate}");
+        }

Review Comment:
   Could this reuse `read_once` further down in this module? It builds the same 
task and reader.
   
   ```suggestion
           let schema = value_schema(Type::Primitive(iceberg_type));
           for (predicate, expected) in cases {
               let (batches, _) =
                   read_once(&path, schema.clone(), vec![1], predicate.clone(), 
false).await;
               let actual = collapse(&batches).map(|batch| 
Arc::clone(batch.column(0)));
               assert_eq!(actual, expected, "{predicate}");
           }
   ```



##########
crates/iceberg/src/arrow/reader/row_filter.rs:
##########
@@ -460,6 +478,89 @@ mod tests {
         }
     }
 
+    /// Writes `column` as field 1, reads it back as `iceberg_type` under each 
predicate, and
+    /// checks the values that survive (`None` when no row does).
+    async fn assert_promoted_reads(
+        column: ArrayRef,
+        iceberg_type: PrimitiveType,
+        cases: Vec<(Predicate, Option<ArrayRef>)>,
+    ) {
+        let tmp = TempDir::new().unwrap();
+        let path = format!("{}/column.parquet", tmp.path().to_str().unwrap());
+        let arrow_schema = Arc::new(ArrowSchema::new(vec![field_with_id(
+            "v",
+            column.data_type().clone(),
+            1,
+        )]));
+        let batch = RecordBatch::try_new(arrow_schema.clone(), 
vec![column]).unwrap();
+        write_row_groups(&path, arrow_schema, vec![batch], false);

Review Comment:
   What do you think about also reading a file that has no field IDs, through a 
name mapping? The transformer finds each filter column by the field-id metadata 
that the `with_schema` hint puts on the filter batch. A column it can't find 
becomes null or its `initial-default` rather than an error 
([`record_batch_transformer.rs`](https://github.com/apache/iceberg-rust/blob/0be54f9076c096e13e3737a6016ab277c495222d/crates/iceberg/src/arrow/record_batch_transformer.rs#L1116-L1147)),
 so a regression there would drop rows silently again. I tried a name-mapped 
`int` file read as `long` with `v < 2147483648`. It returns both rows on the 
head commit and none on `main`. Positional fallback behaves the same. A 
`field_ids: bool` parameter on `assert_promoted_reads` could cover it.



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