Skip to content

fix: accept masked nulls in non-nullable struct children when writing…#7844

Open
mediamana wants to merge 5 commits into
lance-format:mainfrom
mediamana:fix/accept-masked-nulls-non-nullable-struct-children
Open

fix: accept masked nulls in non-nullable struct children when writing…#7844
mediamana wants to merge 5 commits into
lance-format:mainfrom
mediamana:fix/accept-masked-nulls-non-nullable-struct-children

Conversation

@mediamana

Copy link
Copy Markdown

Closes #7826

Problem

compact_files() (and any other read→write roundtrip) fails with
Invalid user input: The field 'x' contained null values even though the field is marked non-null in the schema
on any 2.1 dataset containing at least one null struct whose children are non-nullable.

The write pipeline is self-contradictory for this shape:

  1. The writer's verify_field_nullability rejects any null in a non-nullable child, even when it is masked by the parent struct's validity.
  2. The very next step of the same pipeline, StructStructuralEncoder::maybe_encode, calls pushdown_nulls(), which pushes the parent's validity into the children — producing exactly the masked-null shape the validation just refused.
  3. The decoder materializes the children of null structs as nulls (definition levels do not preserve physical filler values under a null parent), and reassembles them with StructArray::try_new, which accepts masked nulls.

So Lance's own scanner output for a null struct with non-nullable children can never be written back: compaction breaks permanently once a single such row exists. This hits every GeoArrow-style dataset (the spec mandates struct<x: double not null, y: double not null> with validity carried by the struct) that has any row without a geometry.

Fix

verify_field_nullability now accepts nulls in a non-nullable struct child when every null is masked by a null ancestor struct — the same "unmasked nulls" semantics as Arrow's StructArray::try_new. Unmasked nulls are rejected exactly as before.

The relaxation is gated to file versions that encode struct validity (2.1+):

  • 2.0 does not store struct validity (SimpleStruct header, no validity buffer), so a masked child null would read back unmasked and fail StructArray::try_new at decode time. Masked nulls stay rejected there (verified by test).
  • The legacy (v1) writer keeps the strict behavior as well.

No encoder/decoder change is needed: the 2.1 structural encoding already handles this shape end-to-end (repdef layers via pushdown_nulls on write, masked-null children from unravel_validity on read).

Tests

Notes

@github-actions github-actions Bot added A-encoding Encoding, IO, file reader/writer bug Something isn't working labels Jul 19, 2026
@coderabbitai

coderabbitai Bot commented Jul 19, 2026

Copy link
Copy Markdown

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: QUIET

Plan: Pro Plus

Run ID: 24cf504a-57f4-4e35-ba81-ab70a51eb23e

📥 Commits

Reviewing files that changed from the base of the PR and between e503c12 and 5f02bfc.

📒 Files selected for processing (1)
  • rust/lance-file/src/writer.rs

📝 Walkthrough

Walkthrough

Nullability validation is centralized and now accounts for ancestor struct validity when writing format version 2.1 or later. Legacy validation remains strict, while tests cover nested structs and compaction with null structs and non-nullable children.

Changes

Nullability validation

Layer / File(s) Summary
Shared masked-null validator
rust/lance-file/src/writer.rs
Adds recursive validation that permits child nulls fully masked by ancestor struct validity.
Version-gated writer integration
rust/lance-file/src/writer.rs, rust/lance-file/src/previous/writer/mod.rs
Routes current and legacy writer checks through the shared helper, enabling masked child nulls for format version 2.1 and later.
Roundtrip and compaction regression coverage
rust/lance-file/src/writer.rs, rust/lance/src/dataset/optimize.rs
Tests version-specific validation, nested struct handling, and compaction preservation of row counts, struct validity, and child values.

Estimated code review effort: 3 (Moderate) | ~20 minutes

Suggested reviewers: xuanwo

Sequence Diagram(s)

sequenceDiagram
  participant DatasetCompaction
  participant FileWriter
  participant verify_field_nullability
  DatasetCompaction->>FileWriter: write scanner output with masked child nulls
  FileWriter->>verify_field_nullability: validate fields for format version 2.1
  verify_field_nullability-->>FileWriter: accept child nulls masked by parent validity
  FileWriter-->>DatasetCompaction: complete compaction roundtrip
Loading
🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly summarizes the main change: allowing masked nulls in non-nullable struct children during writes.
Description check ✅ Passed The description is detailed and directly describes the same nullability fix, version gating, and tests in this PR.
Linked Issues check ✅ Passed The changes address #7826 by accepting masked child nulls for 2.1+ while keeping unmasked nulls and older formats strict.
Out of Scope Changes check ✅ Passed The added compaction regression test and validation refactor are directly tied to the issue and not out of scope.
Docstring Coverage ✅ Passed Docstring coverage is 100.00% which is sufficient. The required threshold is 80.00%.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Note

Quiet mode is enabled, so only the most important comments were posted inline. Other review comments are grouped below.

🟡 Other comments (1)
rust/lance-file/src/writer.rs-280-284 (1)

280-284: 📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

Include the unmasked null count in the error. The message names the field but drops the diagnostic value that was just computed. Surfacing unmasked_null_count helps distinguish "some rows unmasked" from a fully-unmasked column when a write fails.

♻️ Proposed message enrichment
-            return Err(Error::invalid_input(format!(
-                "The field `{}` contained null values even though the field is marked non-null in the schema",
-                field.name
-            )));
+            return Err(Error::invalid_input(format!(
+                "The field `{}` contained {} null value(s) not masked by a null ancestor struct even though the field is marked non-null in the schema",
+                field.name, unmasked_null_count
+            )));

As per coding guidelines: "Include full context in error messages such as variable names, values, sizes, types, and indices; avoid generic messages."

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@rust/lance-file/src/writer.rs` around lines 280 - 284, Update the error
construction in the null-validation path to include the computed
unmasked_null_count alongside field.name. Preserve the existing invalid-input
error behavior while adding the count value and clear context to the message.

Source: Coding guidelines

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Other comments:
In `@rust/lance-file/src/writer.rs`:
- Around line 280-284: Update the error construction in the null-validation path
to include the computed unmasked_null_count alongside field.name. Preserve the
existing invalid-input error behavior while adding the count value and clear
context to the message.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: QUIET

Plan: Pro Plus

Run ID: 270a7a15-0c19-4f54-a742-ccfcd4338e21

📥 Commits

Reviewing files that changed from the base of the PR and between 252d81a and 7c8c338.

📒 Files selected for processing (3)
  • rust/lance-file/src/previous/writer/mod.rs
  • rust/lance-file/src/writer.rs
  • rust/lance/src/dataset/optimize.rs

@mediamana
mediamana force-pushed the fix/accept-masked-nulls-non-nullable-struct-children branch from 7c8c338 to f57287f Compare July 19, 2026 10:55

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🧹 Nitpick comments (2)
rust/lance-file/src/writer.rs (2)

269-284: 🩺 Stability & Availability | 🔵 Trivial | ⚡ Quick win

Consider a debug_assert! on the subtraction invariant.

combined.null_count() - mask.null_count() relies on NullBuffer::union's "null if null in either input" semantics (confirmed correct via arrow docs), but this is an unenforced assumption from an external library. A future arrow-rs change or an unexpected code path here (e.g. mismatched buffer lengths) would panic in debug builds or silently wrap to a huge usize in release builds, causing spurious rejection of valid writes rather than corrupting data — but still a hard-to-diagnose failure mode.

🛡️ Proposed defensive assertion
             (Some(nulls), Some(mask)) => NullBuffer::union(Some(nulls), Some(mask))
-                .map(|combined| combined.null_count() - mask.null_count())
+                .map(|combined| {
+                    debug_assert!(
+                        combined.null_count() >= mask.null_count(),
+                        "union of nulls must be a superset of the mask"
+                    );
+                    combined.null_count() - mask.null_count()
+                })
                 .unwrap_or(0),

As per coding guidelines, "Prefer debug_assert! over assert! for non-safety invariants" and "Do not silently guard against impossible conditions; use debug_assert!, return an explicit error, or remove the check."

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@rust/lance-file/src/writer.rs` around lines 269 - 284, Add a debug_assert!
before subtracting null counts in the unmasked-null calculation within the
writer’s non-null field validation, verifying combined.null_count() is at least
mask.null_count(). Keep the existing union-based count and error behavior
unchanged.

Source: Coding guidelines


1680-1818: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Good coverage of single-level masking/gating; consider adding a nested (struct-in-struct) case.

This test thoroughly covers masked-vs-unmasked nulls and the 2.0/2.1 gate for one level of struct nesting. The recursive offset-alignment logic in verify_field_nullability (child_arr.slice(arr.offset(), arr.len()) compounding across levels) is exactly the part that a depth-≥2 nested struct (e.g. Struct<Struct<non-nullable f64>>, both levels null-masked) would exercise but isn't tested anywhere in this PR.

As per coding guidelines, "Every bugfix and feature must have corresponding tests," and multi-level nesting is the untested edge of this new recursive logic.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@rust/lance-file/src/writer.rs` around lines 1680 - 1818, Extend
test_masked_nulls_in_non_nullable_struct_child with a depth-two
Struct<Struct<non-nullable Float64>> case where nulls are masked at both parent
levels, including roundtrip assertions for validity and values. Ensure the test
uses the same V2.1 path and exercises recursive offset alignment in
verify_field_nullability, while preserving the existing V2.0 and unmasked-null
coverage.

Source: Coding guidelines

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Nitpick comments:
In `@rust/lance-file/src/writer.rs`:
- Around line 269-284: Add a debug_assert! before subtracting null counts in the
unmasked-null calculation within the writer’s non-null field validation,
verifying combined.null_count() is at least mask.null_count(). Keep the existing
union-based count and error behavior unchanged.
- Around line 1680-1818: Extend test_masked_nulls_in_non_nullable_struct_child
with a depth-two Struct<Struct<non-nullable Float64>> case where nulls are
masked at both parent levels, including roundtrip assertions for validity and
values. Ensure the test uses the same V2.1 path and exercises recursive offset
alignment in verify_field_nullability, while preserving the existing V2.0 and
unmasked-null coverage.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: QUIET

Plan: Pro Plus

Run ID: 8c160853-5832-447e-811c-c6e53ee33b78

📥 Commits

Reviewing files that changed from the base of the PR and between 7c8c338 and f57287f.

📒 Files selected for processing (3)
  • rust/lance-file/src/previous/writer/mod.rs
  • rust/lance-file/src/writer.rs
  • rust/lance/src/dataset/optimize.rs

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Note

Quiet mode is enabled, so only the most important comments were posted inline. Other review comments are grouped below.

🟡 Other comments (1)
rust/lance-file/src/writer.rs-1944-1952 (1)

1944-1952: 📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

Assert the error variant, not just the message.

The negative case only checks err.contains("non-null") on the stringified error. Assert the concrete variant (Error::InvalidInput) as well so a future error-message reword or a different failure path (e.g. a build/IO error also containing that substring) doesn't silently pass this test. Same applies to the sibling assertions in test_masked_nulls_in_non_nullable_struct_child.

As per coding guidelines: "Assert on both the error variant and the message content in tests; do not check only is_err()."

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@rust/lance-file/src/writer.rs` around lines 1944 - 1952, Update the negative
assertions in the current test and its sibling test
test_masked_nulls_in_non_nullable_struct_child to preserve the full error value,
assert that it is the concrete Error::InvalidInput variant, and separately
verify the message contains “non-null”. Replace the current string-only
unwrap_err flow without weakening the existing message check.

Source: Coding guidelines

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Other comments:
In `@rust/lance-file/src/writer.rs`:
- Around line 1944-1952: Update the negative assertions in the current test and
its sibling test test_masked_nulls_in_non_nullable_struct_child to preserve the
full error value, assert that it is the concrete Error::InvalidInput variant,
and separately verify the message contains “non-null”. Replace the current
string-only unwrap_err flow without weakening the existing message check.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: QUIET

Plan: Pro Plus

Run ID: 1ded9ba4-bc9a-4f1a-ab08-2c2fc14ddf9b

📥 Commits

Reviewing files that changed from the base of the PR and between f57287f and e503c12.

📒 Files selected for processing (1)
  • rust/lance-file/src/writer.rs

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

Projects

None yet

Development

Successfully merging this pull request may close these issues.

compact_files() fails on any dataset containing null structs with non-nullable children — scanner null propagation breaks the read→write roundtrip

1 participant