Skip to content

perf(spark): push nested schema pruning down to the native scan (FileFormat connector) - #40

Merged
gitRasheed merged 5 commits into
rasheed/spark-fileformatfrom
rasheed/ff-nested-pruning
Oct 30, 2026
Merged

gitRasheed merged 5 commits into
rasheed/spark-fileformatfrom
rasheed/ff-nested-pruning

Conversation

@gitRasheed

Copy link
Copy Markdown
Collaborator

Summary

Pushes Spark's nested schema pruning down to the native scan on the FileFormat connector. Spark asks for only the nested fields a query uses, but the connector read every nested column whole: SELECT s.t.x.q read the whole of s. Now the scan projects just the requested struct fields, at any depth.

Changes

  • perf(layout): checking a nullable struct field's nulls no longer reads the field. Without it, a projection below a nullable struct still decoded the struct's other fields.
  • feat(spark): the pruned read schema becomes a native projection (select when only leaves are dropped, pack plus a null mask when a child struct is pruned too).
  • fix(spark): Spark's pruned schema is resolved against each file's schema, so case-insensitive names, files with reordered or missing nested fields, and a narrower user schema all read the right fields. Missing nullable fields read as null.
  • fix(spark): files are matched by shape and names without converting leaf types, so an unselected column Spark can't represent (DECIMAL(50,2), unsigned ints) no longer breaks the scan or disables pruning for its parent struct.
  • Enables the narrower-struct Parquet-parity case from test(spark): regression and Parquet-parity tests for the FileFormat connector #39, which this branch fixes.

Structs inside arrays and maps are still read whole here; a follow-up PR handles arrays.

Testing

  • Byte counts through Hadoop FS statistics: s.t.x.q reads 39 KB instead of 10.8 MB, s.a 34 KB.
  • On 200 small files, scan time drops 40% with pruning on; driver planning is unchanged, and resolution costs about 29 µs per file.
  • Tests that fail without the change: the byte-count tests, the mixed-file resolution tests (reordered, missing and differently cased fields), and the two unsupported-type tests (DECIMAL_PRECISION_EXCEEDS_MAX_PRECISION, Unsupported Arrow unsigned integer type).
  • Full check passes: 325 tests in each of Spark 3.5/2.12, 3.5/2.13, 4.0, and the 4.1 and 4.2 compat runs.
  • Reviewed by Astra (three rounds) and Sol.

Checked locally.

I used AI tools to help write and review this change.

…he field

Inside a nullable struct, `is_not_null($.x)` expanded to
`is_not_null(mask($.x, is_not_null($)))`, so the partitioner handed the
field's reader a bare `$` and all of `x` was read just for its validity.
Check the path's own nulls and the parent's validity separately, which
reads only validity.

A pruned projection of a struct inside a nullable struct, such as
`mask(pack(q: $.t.x.q), is_not_null($.t.x))`, needs this. Reading
`s.t.x.q` from Spark with `s`, `t` and `x` nullable read 6.8 MB before
and 39 KB after.

Signed-off-by: gitRasheed <[email protected]>
The V2 scan left Spark's nested schema pruning off and the reader
projected whole top-level columns, so reading `s.a` from a struct `s`
read all of `s`.

The scan builder now opts in, and the reader turns the pruned read
schema into a native projection: `select` when only leaf fields are
pruned, which keeps the struct's nulls, and `mask(pack(...),
is_not_null(...))` when a child struct is pruned too. Structs inside
arrays and maps keep their full type, as the native scan cannot project
list elements yet. This adds the `Expression.mask` JNI binding.

The V1 `FileFormat` path is unchanged: Spark's V1 schema pruning rule
only applies to Parquet and ORC.

Signed-off-by: gitRasheed <[email protected]>
The reader trusted that a read struct had the file's fields, in the
file's order and case. With a supplied schema that did not, it read the
wrong field, failed in the native scan, or indexed past the struct's
children, where Parquet returns the right value or null.

Nested fields now resolve by name against each file's own schema under
Spark's case sensitivity, at every level, and are read under the file's
names. A nullable field the file does not carry reads as null; a
non-nullable one fails naming the field. A struct the file carries none
of the read fields of keeps its own nulls, as Parquet does from Spark
4.1. Pushed filters only bind paths the file carries under the same
names, and Spark evaluates the rest.

Signed-off-by: gitRasheed <[email protected]>
The reader converted every column of a file to a Spark type before
projecting, though only names, nullability and struct shapes are matched
against. A column Spark cannot represent, such as DECIMAL(50, 2), failed
a query that never read it. An unsigned integer inside a struct turned
the whole struct into an opaque column, so reading a sibling field read
the whole struct and failed on the unsigned field.

The file schema now keeps struct shapes and leaves every other type
unconverted, so a column Spark cannot read only fails a query that
reads it.

Signed-off-by: gitRasheed <[email protected]>
@gitRasheed
gitRasheed merged commit 3371cbe into rasheed/spark-fileformat Oct 30, 2026
1 check passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant