Skip to content

[Data] Fix null-type handling in Arrow schema reconciliation and struct alignment - #66521

Open
dragongu wants to merge 3 commits into
ray-project:masterfrom
dragongu:fix/backfill-missing-fields-nested-null
Open

dragongu wants to merge 3 commits into
ray-project:masterfrom
dragongu:fix/backfill-missing-fields-nested-null

Conversation

@dragongu

@dragongu dragongu commented Sep 27, 2026 •

Copy link
Copy Markdown
Contributor

Description

Fix null-type handling in Arrow schema reconciliation and struct alignment.

A nested field that is all-null in one block and a struct in another crashes _backfill_missing_fields with TypeError: 'pyarrow.lib.DataType' object is not iterable. This PR fixes the nested case and one pre-existing bug.

Bug A — Nested all-null field crashes alignment (the original fix)

_align_struct_fields guards top-level columns with isinstance(column.type, pa.StructType), but its recursive call only inspects the target field type, so a nested pa.null() column slips through.

t1 = pa.table({"outer": pa.array([{"inner": {"y": 1}}])})
t2 = pa.table({"outer": pa.array([{"inner": None}, {"inner": None}])})
concat([t1, t2])
# before: TypeError: 'pyarrow.lib.DataType' object is not iterable
# after:  [{"inner": {"y": 1}}, {"inner": None}, {"inner": None}]

Bug B — _reconcile_field step 4 returns pa.null() instead of list type (pre-existing)

The non_null_types parameter actually received pa.null() entries from the caller. Step 3 (struct reconciliation) had its own per-step null filter, but step 4 (null-list reconciliation) iterated the unfiltered list. When the input was [pa.null(), pa.list_(pa.null()), pa.list_(pa.int64())], step 4 returned pa.null() instead of pa.list_(pa.int64()).

unify_schemas([
    pa.schema([("col", pa.null())]),
    pa.schema([("col", pa.list_(pa.null()))]),
    pa.schema([("col", pa.list_(pa.int64()))]),
])
# before: reconciled to null (wrong)
# after:  reconciled to list<int64>

Fixed by filtering pa.null() once at the top of _reconcile_field instead of per-step, and renaming the parameter from non_null_types to field_types to reflect its actual contract.

Reproduction script

Save as repro.py and run on main before this PR to see both bugs. After applying this PR both print OK.

"""Reproduce two null-handling bugs in Ray Data struct alignment.

Prerequisites:  pip install "ray[data]" pyarrow
Usage:          python repro.py
"""
import ray
import pyarrow as pa
ray.init(runtime_env={"env_vars": {"RAY_MINIMAL": "1"}})
from ray.data._internal.arrow_ops.transform_pyarrow import (
    _align_struct_fields, concat, unify_schemas,
)

# Bug A: nested all-null field crashes with TypeError
print("=== Bug A: nested all-null field ===")
t1 = pa.table({"outer": pa.array([{"inner": {"y": 1}}])})
t2 = pa.table({"outer": pa.array([{"inner": None}, {"inner": None}])})
try:
    result = concat([t1, t2])
    print(f"OK: {result['outer'].to_pylist()}")
except TypeError as e:
    print(f"CRASH: TypeError: {e}")
# Expected: OK: [{'inner': {'y': 1}}, {'inner': None}, {'inner': None}]

# Bug B: null arm shadows concrete list type in _reconcile_field step 4
print("\n=== Bug B: null + list<null> + list<int64> ===")
s1 = pa.schema([("col", pa.null())])
s2 = pa.schema([("col", pa.list_(pa.null()))])
s3 = pa.schema([("col", pa.list_(pa.int64()))])
unified = unify_schemas([s1, s2, s3])
col_type = unified.field("col").type
if col_type == pa.list_(pa.int64()):
    print(f"OK: {col_type}")
else:
    print(f"BUG: expected list<int64>, got {col_type}")
# Expected: OK: list<int64>
@dragongu
dragongu requested a review from a team as a code owner September 27, 2026 22:59

@gemini-code-assist gemini-code-assist 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.

Code Review

This pull request improves PyArrow schema unification and struct field alignment in Ray Data. Key changes include stripping null types first during field reconciliation, ensuring struct fields are only reconciled when all arms are structs (preventing incorrect unification of struct and primitive mixes), and properly handling all-null nested fields by filling them with nulls of the target struct type. Comprehensive unit tests are also added. The reviewer suggested a performance optimization in _reconcile_field to immediately return the single type when only one non-null type is present, avoiding unnecessary overhead.

Comment thread python/ray/data/_internal/arrow_ops/transform_pyarrow.py
@dragongu
dragongu force-pushed the fix/backfill-missing-fields-nested-null branch from e225e52 to e888075 Compare September 27, 2026 23:09

@cursor cursor 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.

Cursor Bugbot has reviewed your changes using default effort and found 1 potential issue.

Fix All in Cursor

Reviewed by Cursor Bugbot for commit e888075. Configure here.

Comment thread python/ray/data/_internal/arrow_ops/transform_pyarrow.py
@dragongu
dragongu force-pushed the fix/backfill-missing-fields-nested-null branch from 12a6e99 to 3249dac Compare September 27, 2026 23:19
@ray-gardener ray-gardener Bot added data Ray Data-related issues community-contribution Contributed by the community labels Sep 28, 2026

@iamjustinhsu iamjustinhsu 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.

Wow nice catch! tyty

Comment thread python/ray/data/_internal/arrow_ops/transform_pyarrow.py
Comment thread python/ray/data/_internal/arrow_ops/transform_pyarrow.py
@richardliaw richardliaw added the go add ONLY when ready to merge, run all tests label Sep 30, 2026
@richardliaw

richardliaw commented Sep 30, 2026 •

Copy link
Copy Markdown
Contributor

@iamjustinhsu don't forget to click go/enable auto-merge if ready to merge

…ct alignment

Fix three bugs in the struct alignment / schema reconciliation path:

A) Nested all-null field crashes _backfill_missing_fields with TypeError
   because the recursive call doesn't guard against pa.null() columns.

B) Top-level all-null struct column is silently skipped by
   _align_struct_fields (pre-existing), causing pa.concat_tables to fail
   in default promote mode.

C) _reconcile_field step 4 (null-list reconciliation) iterates the
   unfiltered type list, so a pa.null() arm is returned instead of the
   concrete list type (pre-existing).

Root cause of (C): the parameter named non_null_types actually received
pa.null() entries from the caller. Fix by filtering pa.null() once at
function entry, renaming the parameter to field_types, and simplifying
the struct branch to use all(is_struct(...)).

Signed-off-by: dragongu <andrewgu@vip.qq.com>
@dragongu
dragongu force-pushed the fix/backfill-missing-fields-nested-null branch from 16f77c1 to baae2d0 Compare October 1, 2026 02:44
Address review feedback on the null-handling fix:

* ``_reconcile_diverging_fields`` used to reconcile a field on the fly and
  then skip every later schema for that field. Combined with the early
  return in ``_reconcile_field`` (a single non-null type is already the
  complete result), a field could be locked before a later schema was
  inspected: ``[struct, null, int64]`` silently unified to ``struct``
  instead of raising, and a null arm could shadow a later concrete type.
  Collect all field types first, reconcile once afterwards.

* ``_align_struct_fields`` no longer duplicates the null/struct handling
  that ``_backfill_missing_fields`` already implements; it now delegates
  every mismatched column, which also means all-null columns are aligned
  explicitly instead of relying on PyArrow's promote mode.

Tests:
* ``test_unify_schemas_rejects_late_struct_primitive_mix`` pins that a
  later incompatible type is not hidden by an earlier null arm.
* ``test_align_struct_fields_top_level_non_struct_field`` pins the guard
  for a non-struct column aligned against a struct schema.
* ``test_unify_schemas_null_list_reconciled_when_pyarrow_cannot_unify``
  covers null-list reconciliation when another field forces the
  reconciliation path (PyArrow >= 17 unifies the single-field case on its
  own, so it never reached ``_reconcile_field`` before).

Signed-off-by: dragongu <andrewgu@vip.qq.com>
Review nit: comments were dropped from code whose behaviour did not
change. Restore them and clarify the one branch whose rationale is not
obvious:

* ``# Find first non-null list type`` in ``_reconcile_field`` step 4.
* ``# Check if the column type matches a struct type`` /
  ``# Align struct fields`` / ``# Replace the column with the aligned
  version`` in ``_align_struct_fields``.
* Spell out why ``_backfill_missing_fields`` handles all-null columns
  explicitly rather than relying on PyArrow's promote mode.

Signed-off-by: dragongu <andrewgu@vip.qq.com>
@dragongu
dragongu force-pushed the fix/backfill-missing-fields-nested-null branch from baae2d0 to 07f79f8 Compare October 1, 2026 03:23

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

community-contribution Contributed by the community data Ray Data-related issues go add ONLY when ready to merge, run all tests

3 participants