laskoviymishka commented on code in PR #3328:
URL: https://github.com/apache/iceberg-rust/pull/3328#discussion_r4232149207
##########
crates/iceberg/src/expr/visitors/page_index_evaluator.rs:
##########
@@ -455,6 +544,40 @@ impl<'a> PageIndexEvaluator<'a> {
}
}
+ /// Converts a `FIXED_LEN_BYTE_ARRAY` decimal page bound into a [`Datum`].
+ /// Parquet stores Iceberg decimals with precision > 18 as big-endian
+ /// two's-complement `FIXED_LEN_BYTE_ARRAY` of width `type_length`.
+ ///
+ /// Returns an error for a bound whose width differs from `type_length`:
that
Review Comment:
The guard itself is right — a valid full-width FLBA bound always has exactly
`type_length` bytes, so a short bound can only mean truncation or a broken
writer, and skipping is the safe response. The wording oversells how reachable
it is, though: parquet-rs and parquet-mr's `BinaryTruncator` don't truncate
DECIMAL-annotated bounds, so for a real Iceberg decimal column this never
fires. I'd reframe both this doc comment and the test comment as
defence-in-depth against a non-conforming writer that stores a decimal as plain
FLBA, so nobody reads it as "Iceberg tables hit this path."
##########
crates/iceberg/src/expr/visitors/page_index_evaluator.rs:
##########
@@ -394,14 +408,89 @@ impl<'a> PageIndexEvaluator<'a> {
Ok(page_filter)
}
+ ColumnIndexMetaData::FIXED_LEN_BYTE_ARRAY(idx) => {
+ // Parquet stores Iceberg decimals with precision > 18 as
+ // FIXED_LEN_BYTE_ARRAY. Other field types on such a column
+ // (fixed, uuid) aren't decoded here, so skip page pruning for
+ // them rather than risk pruning pages that match.
+ if !matches!(field_type, PrimitiveType::Decimal { .. }) {
+ tracing::debug!(
+ field_id,
+ %field_type,
+ "Skipping page-index pruning: FIXED_LEN_BYTE_ARRAY
column index on a non-decimal field"
+ );
+ return Ok(None);
+ }
+
+ let Some(type_length) = type_length else {
+ tracing::debug!(
+ field_id,
+ %field_type,
+ "Skipping page-index pruning: unknown
FIXED_LEN_BYTE_ARRAY decimal column width"
+ );
+ 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
+ // by column_index_truncate_length, whose byte prefix no
+ // longer preserves the two's-complement decimal ordering)
+ // means this column's page index can't be trusted, so skip
+ // pruning for the whole column rather than abort the scan.
Review Comment:
The min and max arms are the same closure written out twice, and we build
two `Result`s just to log one `%err` and bail. `decode` captures only
`field_type` and `type_length` (both `Copy`), so the closure is `Copy` and you
can hoist it and reuse it on both sides:
```rust
let decode = |b: &[u8]|
Self::fixed_len_byte_array_decimal_bound_to_datum(field_type, b, type_length);
let (min, max) = match (min.map(decode).transpose(),
max.map(decode).transpose()) {
(Ok(min), Ok(max)) => (min, max),
(Err(err), _) | (_, Err(err)) => {
tracing::debug!(field_id, %field_type, page_index = i, %err, "...");
return Ok(None);
}
};
```
While you're in here: the helper's `DataInvalid` reads as corruption, but a
short bound is legitimate `column_index_truncate_length` behavior that we only
ever log and skip on — I'd soften the kind (or have the helper return
`Option<Datum>`) so a future `?`-caller isn't misled.
##########
crates/iceberg/src/expr/visitors/page_index_evaluator.rs:
##########
@@ -167,6 +168,17 @@ impl<'a> PageIndexEvaluator<'a> {
return self.select_all_rows();
};
+ // Declared byte width of a FIXED_LEN_BYTE_ARRAY column, used to detect
+ // truncated page-index bounds when decoding decimals. `None` when the
+ // column metadata is absent or reports no fixed width.
+ let type_length = self
+ .row_group_metadata
+ .columns()
+ .get(parquet_column_index)
+ .map(|column| column.column_descr().type_length())
+ .filter(|&len| len > 0)
+ .map(|len| len as usize);
Review Comment:
`len as usize` is safe here (positive `i32`), but
`usize::try_from(len).ok()` keeps it lossless-by-construction. Bigger picture:
this runs for every predicate column though only the FLBA arm reads it, and it
widens the shared helper signature by a positional arg — I'd lean toward
deriving `type_length` lazily inside the arm (or passing the
`ColumnDescriptor`) so the next metadata-hungry arm doesn't add another. Not
blocking either way.
##########
crates/iceberg/src/expr/visitors/page_index_evaluator.rs:
##########
@@ -1880,6 +2051,141 @@ mod tests {
Ok(())
}
+ #[test]
+ fn eval_inequality_prunes_fixed_len_byte_array_decimal_pages() ->
Result<()> {
+ // precision 30 -> Parquet FIXED_LEN_BYTE_ARRAY page bounds.
+ let (metadata, _temp_file) = create_decimal_parquet_file(30, 2, &[100,
200, 300, 400])?;
+ let (column_index, offset_index, row_group_metadata) =
get_test_metadata(&metadata);
+ let (iceberg_schema, field_id_map) =
build_decimal_schema_and_field_map(30, 2)?;
+
+ // Pages hold 1.00, 2.00, 3.00, 4.00. `> 2.50` keeps the pages whose
+ // upper bound exceeds 2.50 (3.00 and 4.00).
+ let filter = Reference::new("col_decimal")
+ .greater_than(decimal_datum(250, 2, 30)?)
+ .bind(iceberg_schema.clone(), false)?;
+
+ let result = PageIndexEvaluator::eval(
+ &filter,
+ &column_index,
+ &offset_index,
+ row_group_metadata,
+ &field_id_map,
+ iceberg_schema.as_ref(),
+ )?;
+
+ assert_eq!(result, vec![
+ RowSelector::skip(2048),
+ RowSelector::select(2048)
+ ]);
+
+ Ok(())
+ }
+
+ #[test]
+ fn eval_in_prunes_fixed_len_byte_array_decimal_pages() -> Result<()> {
+ // precision 30 -> Parquet FIXED_LEN_BYTE_ARRAY page bounds.
+ let (metadata, _temp_file) = create_decimal_parquet_file(30, 2, &[100,
200, 300, 400])?;
+ let (column_index, offset_index, row_group_metadata) =
get_test_metadata(&metadata);
+ let (iceberg_schema, field_id_map) =
build_decimal_schema_and_field_map(30, 2)?;
+
+ // Pages hold 1.00, 2.00, 3.00, 4.00. IN (2.00, 4.00) keeps only the
+ // pages whose single value is one of the literals.
+ let filter = Reference::new("col_decimal")
+ .is_in([decimal_datum(200, 2, 30)?, decimal_datum(400, 2, 30)?])
+ .bind(iceberg_schema.clone(), false)?;
+
+ let result = PageIndexEvaluator::eval(
+ &filter,
+ &column_index,
+ &offset_index,
+ row_group_metadata,
+ &field_id_map,
+ iceberg_schema.as_ref(),
+ )?;
+
+ assert_eq!(result, vec![
+ RowSelector::skip(1024),
+ RowSelector::select(1024),
+ RowSelector::skip(1024),
+ RowSelector::select(1024),
+ ]);
+
+ Ok(())
+ }
+
+ #[test]
+ fn eval_inequality_prunes_negative_fixed_len_byte_array_decimal_pages() ->
Result<()> {
+ // precision 30 -> Parquet FIXED_LEN_BYTE_ARRAY page bounds, spanning
+ // negative values. Negative decimals have a 0xff high byte in two's
+ // complement, so this only prunes correctly if the bounds are compared
+ // numerically rather than by raw byte order.
+ let (metadata, _temp_file) = create_decimal_parquet_file(30, 2,
&[-400, -200, 100, 300])?;
+ let (column_index, offset_index, row_group_metadata) =
get_test_metadata(&metadata);
+ let (iceberg_schema, field_id_map) =
build_decimal_schema_and_field_map(30, 2)?;
+
+ // Pages hold -4.00, -2.00, 1.00, 3.00. `>= -2.00` keeps the pages
whose
+ // upper bound is at least -2.00, including page 1 whose bound equals
it.
+ let filter = Reference::new("col_decimal")
+ .greater_than_or_equal_to(decimal_datum(-200, 2, 30)?)
+ .bind(iceberg_schema.clone(), false)?;
+
+ let result = PageIndexEvaluator::eval(
+ &filter,
+ &column_index,
+ &offset_index,
+ row_group_metadata,
+ &field_id_map,
+ iceberg_schema.as_ref(),
+ )?;
+
+ assert_eq!(result, vec![
+ RowSelector::skip(1024),
+ RowSelector::select(3072)
+ ]);
+
+ Ok(())
+ }
+
+ #[test]
+ fn eval_skips_pruning_for_truncated_fixed_len_byte_array_decimal_bound()
-> Result<()> {
Review Comment:
The new tests all land on precision 30 (13-byte bounds), so the width
extremes aren't covered. Two worth adding: a precision-38 column (16-byte, hits
the `type_length == 16` boundary and the full-width negative extreme), and a
narrow-width-widened-precision case — a column physically stored below the
field precision's minimum width (e.g. a 9-byte FLBA read as decimal(30,2)) —
which is where the sign extension in `i128_from_be_bytes` actually earns its
keep. Not blocking, but they'd pin the decode down across the width range.
##########
crates/iceberg/src/expr/visitors/page_index_evaluator.rs:
##########
@@ -1880,6 +2051,141 @@ mod tests {
Ok(())
}
+ #[test]
+ fn eval_inequality_prunes_fixed_len_byte_array_decimal_pages() ->
Result<()> {
Review Comment:
All four new tests use non-null data, so the `None`-bound / null-count path
on this arm (the `idx.null_count(i)` + `None, None` into the predicate) isn't
exercised. I'd add one test with a null-only page — an `IS NULL`, or a `> x`
that should keep the null page — so a bad refactor of the None handling here
gets caught.
--
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]