lance-format / lance-format/lance
`compact_files()` fails on any dataset containing null structs with non-nullable children — scanner null propagation breaks the read→write roundtrip
Nobody has claimed this yet.
- Dominant language
- Rust
- Stars
- 7.1k
- Forks
- 852
- Avg merge
- 3d 18h
- Merged PRs (30d)
- 272
Description
Dataset.optimize.compact_files() fails with Invalid user input: The field 'x' contained null values even though the field is marked non-null in the schema on any dataset that contains at least one null struct whose child fields are declared non-nullable — even though the data on disk is perfectly valid.
The root cause is a contradiction between Lance's read path and its write validation:
- On write, data following the "validity-first" convention is accepted: parent struct null (validity carried by the struct), children physically filled with
0.0and declarednot null. This is exactly how the GeoArrow spec mandates point columns (x/ychildren MUST be non-nullable, nullability is carried by the outer struct). - On read, the scanner applies Arrow null propagation: children of a null struct come back as nulls, the physical fillers are not surfaced.
- Compaction is a read→write roundtrip: the writer then validates the scanner's own output against the schema, sees nulls in a non-nullable child, and rejects it.
Net effect: Lance refuses to rewrite data it just read itself. A single row with a null struct makes the whole dataset permanently un-compactable (and the same applies to any other internal rewrite path with the same roundtrip).
Reproduction (20 lines)
import pyarrow as pa
import lance
# Valid "validity-first" data: parent struct null, children physically 0.0, declared non-null
# (this is the GeoArrow point layout: x/y children are "not null" per spec)
x = pa.array([1.0, 0.0, 3.0, 0.0, 5.0], type=pa.float64())
y = pa.array([1.0, 0.0, 3.0, 0.0, 5.0], type=pa.float64())
mask = pa.array([False, True, False, True, False]) # True = parent struct is null
fields = [pa.field("x", pa.float64(), nullable=False),
pa.field("y", pa.float64(), nullable=False)]
pos = pa.StructArray.from_arrays([x, y], fields=fields, mask=mask)
tbl = pa.table({"id": pa.array([1, 2, 3, 4, 5], type=pa.int64()), "position": pos})
path = "repro.lance"
lance.write_dataset(tbl.slice(0, 2), path, mode="create") # write is ACCEPTED
lance.write_dataset(tbl.slice(2, 3), path, mode="append")
ds = lance.dataset(path)
back = ds.to_table()["position"].combine_chunks()
print("parent nulls:", back.null_count, "| child x nulls:", back.field(0).null_count)
# -> parent nulls: 2 | child x nulls: 2 (fillers lost, nulls propagated to child on read)
ds.optimize.compact_files() # -> OSError: Invalid user input: The field `x` contained
# null values even though the field is marked non-null
Expected behavior
compact_files() should succeed on any dataset that Lance itself accepted at write time. Either:
- the compaction path should preserve/restore the physical child values under null parents (keep the validity-first layout), or
- the writer validation should accept child nulls that are fully masked by the parent struct's validity bitmap.
Actual behavior
OSError: Invalid user input: The field 'x' contained null values even though the field is marked non-null in the schema, rust/lance-file/src/writer.rs:397
Environment
- Reproduced on pylance 6.0.0, 7.0.0 and 8.0.0 (latest) — including with a dataset created by 8.0.0 itself (pyarrow 25.0.0)
- Python 3.12/3.13, Windows and Linux x86_64, local filesystem
- Dataset format v2.x
Impact
This is particularly relevant to the ongoing geospatial work (#4482): the GeoArrow spec requires point columns to be struct<x: double not null, y: double not null> with nullability on the struct. Any real-world geo dataset has rows without a geometry → every GeoArrow-style dataset with nullable geometries becomes un-compactable. In our production fleet-telemetry deployment (geoarrow.point column, ~10-75% of rows without GPS), compaction has been failing on 100% of datasets, silently accumulating 1,000-1,500 small data files per daily dataset.
Workaround we use
Full rewrite: read the table, restore the validity-first layout (fill child nulls with 0.0, keep the parent validity bitmap), write_dataset(mode="overwrite"), recreate indices. The rewritten dataset compacts natively again — until new null-struct rows arrive.
Related
- lance-format/lance#1936 (write-side validation of unmasked nulls — the fix there is what now rejects the scanner's own output)
- apache/arrow-rs#6727 (
StructArrayoperations don't reconcile child nullability) - lancedb/lancedb#3105 (mirror symptom: null structs read back as non-null objects with default values)
Contributor guide
First steps
- Read the whole issue, then the project's contributing guide.
- Comment on the issue to say you are picking it up — it saves two people doing the same work.
- Fork the repository and make your change on a branch.
- Open a pull request that references the issue number.
Research direction
Start with the supplied Python reproduction and trace compact_files() through the scanner read→write path. Inspect rust/lance-file/src/writer.rs around line 397, along with the compaction entry points, to determine how null struct validity and non-nullable children are handled. Done means the reproduction compacts successfully without rejecting child nulls masked by a null parent.
Written by the indexing model from the issue text.
Assessment
- Tech stack
- python, rust
- Domain
- data, databases
- Issue type
- Bug
- Difficulty
- 4/5
- Estimated time
- 3-5 days
- Activity status
- Quiet
- Clarity
- Clearly specified
- Newbie friendliness
- 52/100