Repository navigation
feat(reader): page-index pruning for FIXED_LEN_BYTE_ARRAY decimals - #3328
Conversation
Decimals with precision > 18 are stored as FIXED_LEN_BYTE_ARRAY, which the
page-index evaluator skipped, so inequality and IN predicates on them got no
page pruning (over-selection only, never dropped rows). Decode those bounds
with i128_from_be_bytes, matching the row-group statistics path.
Guard against truncated ColumnIndex bounds: a bound whose width differs from
the column's declared type_length signals a column_index_truncate_length
truncation, and a truncated byte prefix no longer preserves the two's
complement numeric ordering of decimals. Reject it so the column skips pruning
rather than decode the wrong number. parquet-rs never truncates decimal column
indexes, so this only defends against files written by other implementations.
Benchmark
A throwaway benchmark on a 1,000,000-row single-column decimal(30,2) file
(precision > 18 -> FIXED_LEN_BYTE_ARRAY), 100 pages in one row group, read
fully in memory so timing is decode-only. A `col > threshold` predicate sweeps
selectivity. "read full file" is today's behavior (FLBA decimals skip page
pruning); "read ms" applies the page-index RowSelection this change produces.
1,000,000 rows in 100 pages, 1 row group
read full file : 3.431 ms
predicate (keep) | rows read | eval us | read ms | speedup
1% | 10,000 | 17.5 | 0.038 | 90.0x
10% | 100,000 | 17.1 | 0.330 | 10.4x
50% | 500,000 | 16.6 | 1.629 | 2.1x
100% | 1,000,000 | 16.3 | 3.397 | 1.0x
The win tracks predicate selectivity; eval() adds a constant ~17 us per row
group per scan, negligible against the millisecond read. The benefit only
materializes where page-index row selection runs: MoR tables or
with_row_selection_enabled(true). Reproducer in the PR description.
Follow-up to apache#3314.
laskoviymishka
left a comment
There was a problem hiding this comment.
The core of this is right — decoding FLBA decimals as big-endian two's-complement i128 with sign extension matches the row-group stats path, the decode is panic-free (i128_from_be_bytes returns Option, no unchecked indexing), and skipping pruning for the whole column on any undecodable bound is the correct conservative failure mode rather than aborting a file that's valid for other readers. The width-equality guard is sound too: a valid full-width FLBA bound always has exactly type_length bytes.
A few things I'd tighten, none blocking:
- The width-guard rationale (doc + test comment) oversells how reachable it is — parquet-rs and parquet-mr don't truncate DECIMAL-annotated bounds, so for a real Iceberg decimal column this guard never fires. Worth reframing as defence-in-depth against a non-conforming writer so it doesn't read as "Iceberg tables hit this."
- The min/max decode is the same closure written twice to log one error; hoisting a single
Copyclosure and keeping thetranspose()flow collapses it. While there,DataInvalidreads as corruption for what's really legitimate truncation. - Test coverage leans entirely on precision-30 (13-byte) non-null data — a null-only page and the width extremes (precision 38 at 16 bytes, and a narrow-width-widened-precision decode) would pin down the None handling and the sign extension.
One observation, not for this PR: the row-group stats path (get_parquet_stat_max_as_datum) still calls i128_from_be_bytes with no width check, so it's now less strict than this new page-index path. Worth a note or a follow-up for symmetry.
Specifics are in the inline comments.
| /// 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 |
There was a problem hiding this 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."
There was a problem hiding this comment.
In the followup, I reworded the helper doc and the inline arm comment to frame the guard as defence against a non-conforming writer.
| // 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. |
There was a problem hiding this comment.
The min and max arms are the same closure written out twice, and we build two Results 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:
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.
There was a problem hiding this comment.
Thanks. Hoisted the decode into a single Copy closure. Kept Result<Datum> and DataInvalid: a short/undecodable bound isn't normal behavior for a DECIMAL-annotated FLBA, so DataInvalid is the right signal. Happy to move both to Option in a follow-up if you'd prefer.
| .get(parquet_column_index) | ||
| .map(|column| column.column_descr().type_length()) | ||
| .filter(|&len| len > 0) | ||
| .map(|len| len as usize); |
There was a problem hiding this 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.
There was a problem hiding this comment.
Kept len as usize: it's guarded by .filter(|&len| len > 0), so the cast is on a positive i32 and lossless
| } | ||
|
|
||
| #[test] | ||
| fn eval_inequality_prunes_fixed_len_byte_array_decimal_pages() -> Result<()> { |
There was a problem hiding this 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.
There was a problem hiding this comment.
Added eval_is_null_selects_all_null_fixed_len_byte_array_decimal_page to cover this.
| } | ||
|
|
||
| #[test] | ||
| fn eval_skips_pruning_for_truncated_fixed_len_byte_array_decimal_bound() -> Result<()> { |
There was a problem hiding this 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.
…d docs Address review feedback on apache#3328: - Reframe the width-guard doc and inline comment as defence-in-depth against a non-conforming writer - Hoist the duplicated min/max decode into a single closure. - Add page-index tests
…apache#3386) test(reader): widen FLBA decimal page-index coverage and clarify guard docs Address review feedback on apache#3328: - Reframe the width-guard doc and inline comment as defence-in-depth against a non-conforming writer - Hoist the duplicated min/max decode into a single closure. - Add page-index tests
Which issue does this PR close?
No separate issue. This is the
FIXED_LEN_BYTE_ARRAYfollow-up called outin the description of #3314
What changes are included in this PR?
Decimals with precision > 18 are stored as
FIXED_LEN_BYTE_ARRAY, which the page-index evaluator skipped entirely, so inequality andINpredicates on them got no page pruning.FIXED_LEN_BYTE_ARRAYdecimal page bounds withi128_from_be_bytes, matching the row-group statistics path (get_parquet_stat_{min,max}_as_datum), so the two pruning layers agree on the P > 18 encoding.ColumnIndexbounds: a bound whose width differs from the column's declaredtype_lengthsignals acolumn_index_truncate_lengthtruncation, and a truncated two's-complement byte prefix decodes to the wrong number. Reject it so the column skips pruning rather than decode a wrong bound. parquet-rs never truncates decimal/float16 column indexes (can_truncate_valueexcludes them, since their sort order differs from raw byte order), so this only defends against files written by other engines.FIXED_LEN_BYTE_ARRAYfields (fixed, uuid) are still not decoded here, so they skip page pruning rather than risk pruning matching pages.Benchmark
A throwaway benchmark on a 1,000,000-row single-column
decimal(30,2)file (precision > 18 →FIXED_LEN_BYTE_ARRAY), 100 pages in one row group, read fully in memory so timing is decode-only. Acol > thresholdpredicate sweeps selectivity. "read full file" is today's behavior (FLBA decimals skip page pruning); "read ms" applies the page-indexRowSelectionthis change produces.Throwaway benchmark
Run with:
Are these changes tested?
Unit tests in
page_index_evaluator