Skip to content

fix: count only visible nulls in the 2.1+ writer - #9020

Merged
jackye1995 merged 4 commits into
lance-format:mainfrom
jackye1995:jack/writer-visible-nulls-only
Sep 6, 2026
Merged

fix: count only visible nulls in the 2.1+ writer#9020
jackye1995 merged 4 commits into
lance-format:mainfrom
jackye1995:jack/writer-visible-nulls-only

Conversation

@jackye1995

Copy link
Copy Markdown
Contributor

A struct column this writer produced cannot be written back after a round trip.

verify_field_nullability walked into a struct's children without regard for the parent's validity, so a null in a non-nullable child failed the write even where a null parent slot masked it:

Invalid user input: The field `changed` contained null values even though
the field is marked non-null in the schema

A reader hands back exactly that. A null struct slot leaves its children undefined, and they materialize as null — so write(read(write(x))) fails on data this writer had just accepted. It showed up in a filtered UDF refresh, which merges stored values with recomputed ones and therefore re-writes rows it read; a full refresh writes only freshly built arrays and never hit it.

Arrow draws the line where this now does. StructArray::try_new rejects only the nulls in a non-nullable child that the parent's null mask does not cover, and in fact refuses to construct the array this check was reporting:

InvalidArgumentError("Found unmasked nulls for non-nullable StructArray field \"changed\"")

The 2.0 writer already documented the split — "the 2.1+ structural writer counts only visible nulls (writer::nullability)" — but that module was never written. The comment now points at the real thing, and 2.0 keeps rejecting masked nulls, since its logical encoders genuinely cannot store the slot.

Struct children are the only ones that sit one slot per parent slot, so they are the only ones masked; list-like children are addressed through offsets and are checked as before. Children are sliced to the parent's window first so a sliced batch lines up, which the third test covers.

A struct column that this writer produced could not be written back after a
round trip. verify_field_nullability walked into a struct's children without
regard for the parent's validity, so a null in a non-nullable child failed the
write even where a null parent slot masked it -- and a reader hands back
exactly that, because a null struct slot leaves its children undefined and
they materialize as null.

Arrow draws the line where this now does: StructArray::try_new rejects only
the nulls in a non-nullable child that the parent's null mask does not cover,
and refuses to build the array this check was reporting. The 2.0 writer
already documented the split, pointing at a writer::nullability that was never
written; that comment now points at the real thing, and 2.0 keeps rejecting
masked nulls because its logical encoders genuinely cannot store the slot.

Struct children are the only ones that sit one slot per parent slot, so they
are the only ones masked; list-like children are addressed through offsets and
are checked as before. Children are sliced to the parent's window first, so a
sliced batch lines up.
@github-actions github-actions Bot added A-encoding Encoding, IO, file reader/writer bug Something isn't working labels Sep 6, 2026
lance-gatekeeper[bot]

This comment was marked as outdated.

@lance-gatekeeper lance-gatekeeper Bot added the K-changes Latest Gatekeeper recommendation requests changes. label Sep 6, 2026
@lance-gatekeeper lance-gatekeeper Bot removed the K-changes Latest Gatekeeper recommendation requests changes. label Sep 6, 2026
lance-gatekeeper[bot]

This comment was marked as outdated.

@lance-gatekeeper lance-gatekeeper Bot added the K-changes Latest Gatekeeper recommendation requests changes. label Sep 6, 2026
The reachability mask was applied to physical validity, which does not carry
every null Arrow can see. A dictionary key pointing at a null value is a null
with no bit in the validity buffer, so masking physical validity alone let one
through into a file Arrow refuses on readback -- the writer would have
produced something it could not read.

Reading nulls through logical_nulls closes that, and matches what
StructArray::try_new validates against, which is the contract this check is
meant to keep. Working from typed arrays also removes the manual child slicing:
StructArray::columns are already cut to the parent's window.

The regression case is the dictionary the gate described -- values [10, null],
keys [0, 1, null], parent validity [true, true, false] -- where row 2's
physical null is masked and row 1's logical null is not.
@lance-gatekeeper lance-gatekeeper Bot removed the K-changes Latest Gatekeeper recommendation requests changes. label Sep 6, 2026

@lance-gatekeeper lance-gatekeeper Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Gate recommendation: approve.

Both earlier findings are resolved. The 2.1+ writer now evaluates Arrow logical nulls only where struct slots are reachable, preserving masked child nulls across write/read/write while still rejecting visible logical nulls. The focused regressions cover masked, unmasked, sliced-parent, and dictionary cases.

@lance-gatekeeper lance-gatekeeper Bot added the K-approved Latest Gatekeeper recommendation permits acceptance. label Sep 6, 2026
@jackye1995
jackye1995 merged commit f17fc0a into lance-format:main Sep 6, 2026
36 of 37 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

A-encoding Encoding, IO, file reader/writer bug Something isn't working K-approved Latest Gatekeeper recommendation permits acceptance.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant