[core][format][spark] Support nested field predicate pushdown - #9423
[core][format][spark] Support nested field predicate pushdown#9423zhuxiangyi wants to merge 1 commit into
Conversation
| throw new UnsupportedOperationException(); | ||
| } | ||
| NestedFieldTransform nested = (NestedFieldTransform) predicate.transform(); | ||
| FieldRef pathRef = new FieldRef(UNUSED_INDEX, nested.fieldName(), nested.outputType()); |
There was a problem hiding this comment.
[P1] Preserve the full nested path for DECIMAL and TIMESTAMP predicates
This re-dispatches the nested transform as a FieldRef named "payload.amount", but the decimal, timestamp, and local-zoned-timestamp visitors later build FilterApi columns from primitiveType.getName(), which is only "amount". For a predicate such as payload.amount = 12.34, parquet-mr therefore receives a missing top-level column and its statistics filter can drop every row group as all-null, producing an empty result. Please keep the resolved FileColumn.path when validating these physical types and use that full path to construct the predicate column; regression tests should cover nested DECIMAL and both timestamp variants against actual row groups.
|
|
||
| @Override | ||
| public Transform copyWithNewInputs(List<Object> inputs) { | ||
| checkArgument(inputs.size() == 1); |
There was a problem hiding this comment.
[P1] Re-resolve nested identity when inputs are remapped
This preserves an ordinal path even when the replacement FieldRef has a different nested RowType. Nested transforms are now JSON-serializable and can be used by REST row filters, so a policy on info.secret with path [0] against ROW<secret, region> can be remapped against a Spark-pruned ROW and silently evaluate info.region instead. With same-typed fields this does not fail closed and can admit unauthorized rows. Please persist stable nested names or field IDs and re-resolve them during remapping, while ensuring auth reads the full nested dependencies; alternatively, reject nested transforms in row filters until their identity can be preserved.
| "Nested field position %s is out of range for %s.", | ||
| position, | ||
| rowType); | ||
| nameBuilder.append('.').append(rowType.getFields().get(position).name()); |
There was a problem hiding this comment.
[P2] Preserve multipart field-name boundaries
Joining the resolved components with dots loses identifier boundaries. For a valid schema such as ROW<s ROW<"a.b" STRING>>, Spark supplies the parts [s, a.b], but this transform emits s.a.b and ParquetFilters later splits it into [s, a, b]. parquet-mr then treats the real [s, a.b] column as missing and may prune matching row groups. Please retain the ordered components and construct the Parquet ColumnPath from that array; at minimum, decline Parquet pushdown whenever a nested component contains a dot.
9292580 to
ec9d2bd
Compare
Predicates on a struct's sub-field are not pushed down today. `SparkExpressionConverter` rejects any `NamedReference` with more than one part, so `WHERE user.addr.city = 'Beijing'` is only evaluated by the engine after every row has been read. This PR pushes such predicates down to the parquet row group / page level. **Design.** Introduce `NestedFieldTransform`, a `Transform` holding the enclosing top-level `FieldRef` plus the ordered field names to descend into it. It is deliberately **not** a `FieldTransform`, so `LeafPredicate.fieldRefOptional()` stays empty for these predicates, and every consumer that equates a leaf with a top-level column — manifest stats evaluation, file index lookup, ORC pushdown, schema evolution rewriting, partition-only predicate detection — falls into its existing give-up path unchanged. That is why the diff contains no defensive guards at those call sites. The path is stored as component names rather than positions and is re-resolved by name whenever the transform is remapped onto a different row type (as column pruning and row-level auth do): a leaf that was pruned away fails closed instead of silently resolving onto whatever now sits at that position, and a reordered row type still finds the original field. The parquet side resolves the dotted name against the file schema and re-dispatches through the normal function visitor via the existing `visitNonFieldLeaf` hook, so every pushable function works on a nested field exactly as it does on a flat one, without per-function code. Decimal and timestamp predicates carry the file's own resolved path rather than the leaf's bare name, so they address the same column a flat predicate would. **Supported.** `IS NULL`, `IS NOT NULL`, `=`, `<>`, `<`, `<=`, `>`, `>=`, `BETWEEN`, `IN`, `NOT IN`, and `AND`/`OR` mixing a nested field with a top-level one. Any nesting depth. **Refused, falling back to engine evaluation:** - any path component under a repeated group — parquet-mr cannot filter under repetition; - a path descending into a non-row type; - the enclosing column's name or any nested component containing a dot — parquet-mr addresses a column by a dot-joined path that cannot express such a name, so resolving it would either miss the real column or, on an unlucky schema, address a different one; - everything the flat path already refuses (`startsWith` / `endsWith` / `contains` / `like`). **Deliberately out of scope**, each independent of this change: - *Manifest-level min/max and file index pruning.* `SimpleStats` is a positional row over top-level columns, so a nested leaf has no slot to read from — pushdown here is parquet-only. Making those layers work on nested fields requires reorganising statistics by field id, which is a separate and much larger change. - *Schema evolution.* A data file whose schema version predates the table's current schema does not get this pushdown: `SchemaEvolutionUtil.devolveFilters` translates a predicate by its top-level `FieldRef`, which a nested predicate deliberately does not expose, so such predicates are dropped for those files. Results stay correct — the engine still evaluates the filter — but the parquet-level pruning is gone until a later write or compaction rewrites the files under the current schema. - *ORC.* `OrcPredicateFunctionVisitor.visitNonFieldLeaf` returns empty and is untouched. - *Flink.* `PredicateConverter` does not produce nested predicates today (a nested access arrives as a `GET` call, not a `FieldReferenceExpression`), so the Flink path never constructs a `NestedFieldTransform` and its behaviour is unchanged. **Existing tables are unaffected.** No format change, no new option, read path only. Pruning uses row group and page statistics that are already present in existing files, so no rewrite or compaction is needed. Pushdown remains an optimisation: a non-partition data filter is also kept in `postScan`, so Spark still evaluates it row by row and the result set cannot change. **Measured** on 400k rows with a wide struct, counting real bytes read: | data layout | point `=` | `BETWEEN`, 1% | `BETWEEN`, 10% | absent value | | --- | --- | --- | --- | --- | | clustered on the nested field | 5.1% | 5.1% | 15.1% | 0.03% (footer only) | | zone-ordered | 5.1% | 10.1% | 15.1% | 0.03% | | randomly distributed | 100% | 100% | 100% | 0.03% | A flat control column matched the nested column in every case. As with any min/max based pruning, the gain depends entirely on data locality. This also adds the missing `PredicateBuilder.notIn(Transform, List)` overload — `notIn` was the only builder method without a `Transform` variant. Tests reproduce every guarded scenario end to end against real row groups (`ParquetFormatReadWriteTest`) as well as at the filter-construction and transform level: nested predicates surviving remapping onto a pruned or reordered row type, decimal predicates across every physical type parquet can hold them in, both timestamp variants, and a nested or top-level component whose name contains a dot. Run against Spark 3.3, 3.4 and 3.5. No change to any on-disk format, no new table option. `NestedFieldTransform` is registered as a `Transform` subtype so predicates serialise and deserialise like the existing ones.
ec9d2bd to
cac76a8
Compare
Purpose
Predicates on a struct's sub-field are not pushed down today.
SparkExpressionConverterrejects anyNamedReferencewith more than one part, soWHERE user.addr.city = 'Beijing'is only evaluated by the engine after every row has been read.This PR pushes such predicates down to the parquet row group / page level.
Design. Introduce
NestedFieldTransform, aTransformholding the enclosing top-levelFieldRefplus the positions to descend into it. It is deliberately not aFieldTransform, soLeafPredicate.fieldRefOptional()stays empty for these predicates, and every consumer that equates a leaf with a top-level column — manifest stats evaluation, file index lookup, ORC pushdown, schema evolution rewriting, partition-only predicate detection — falls into its existing give-up path unchanged. That is why the diff contains no defensive guards at those call sites.The parquet side resolves the dotted name against the file schema and re-dispatches through the normal function visitor via the existing
visitNonFieldLeafhook, so every pushable function works on a nested field exactly as it does on a flat one, without per-function code.Supported.
IS NULL,IS NOT NULL,=,<>,<,<=,>,>=,BETWEEN,IN,NOT IN, andAND/ORmixing a nested field with a top-level one. Any nesting depth.Refused, falling back to engine evaluation:
startsWith/endsWith/contains/like).Deliberately out of scope, each independent of this change:
SimpleStatsis a positional row over top-level columns, so a nested leaf has no slot to read from — pushdown here is parquet-only. Making those layers work on nested fields requires reorganising statistics by field id, which is a separate and much larger change.OrcPredicateFunctionVisitor.visitNonFieldLeafreturns empty and is untouched.PredicateConverterdoes not produce nested predicates today (a nested access arrives as aGETcall, not aFieldReferenceExpression), so the Flink path never constructs aNestedFieldTransformand its behaviour is unchanged.Existing tables are unaffected. No format change, no new option, read path only. Pruning uses row group and page statistics that are already present in existing files, so no rewrite or compaction is needed. Pushdown remains an optimisation: a non-partition data filter is also kept in
postScan, so Spark still evaluates it row by row and the result set cannot change.Measured on 400k rows with a wide struct, counting real bytes read:
=BETWEEN, 1%BETWEEN, 10%A flat control column matched the nested column in every case. As with any min/max based pruning, the gain depends entirely on data locality.
This also adds the missing
PredicateBuilder.notIn(Transform, List)overload —notInwas the only builder method without aTransformvariant.Tests
NestedFieldTransformTest(9 new) — one and two level reads; a null anywhere on the path yields null; a predicate on null evaluates false; noFieldRefis exposed; stats never prune; projection keeps the path; JSON round trip; a path through a non-row type is rejected.ParquetFiltersTest(4 new) — nested field pushdown; every pushable function on a nested field, plusAND-mixing with a top-level column andstartsWithrejection; a nested field under a repeated group is not pushed down; a nested field missing from the file.SparkV2FilterConverterTestBase(2 new) — end to end oninfo.uidandinfo.addr.city, asserting the predicate reachespushedDataFilters, thatfieldRefOptional()is empty, and thatfieldNames()reports the enclosing top-level column; plus a top-level column whose name contains a dot, asserting it resolves to aFieldTransformrather than being split into a path.Run against Spark 3.3, 3.4 and 3.5 (32/32 each).
PaimonPushDownTestis regression-clean at 21/21.API and Format
No change to any on-disk format, no new table option, no new configuration.
NestedFieldTransformis registered as aTransformsubtype so predicates serialise and deserialise like the existing ones.Documentation
None required — no user-facing option is added; the behaviour change is that an existing query plan gains a pushed filter.