fix: preserve field nullability in empty Arrow tables (ITL-563)#242
Conversation
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…(ITL-563) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…(ITL-563) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…(ITL-563) Adds make_empty_table() which uses python_schema_to_arrow_schema() to build zero-row Arrow tables with correct nullable flags (nullable=False for plain types, nullable=True for T | None), fixing the nullability loss that caused downstream Join operators to raise InputValidationError on empty outputs. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…n make_empty_table (ITL-563)
…y (ITL-563) Replace the old _make_empty_table() body (python_type_to_arrow_type loop + pa.table(dict), which lost nullable=False for plain types) with a call to arrow_utils.make_empty_table(), which uses python_schema_to_arrow_schema and pa.Table.from_batches to correctly set nullable per field. Also strengthen test_as_table_empty_schema_matches_non_empty_schema to assert that field nullability matches between the empty and populated tables.
…_empty_table (ITL-563)
…ility (ITL-563) Replace the empty-branch of _materialize_as_stream() to call arrow_utils.make_empty_table() instead of building a pa.table() from a dict of pa.array() calls, which was marking all fields nullable=True. Also fix Join.static_process() to use an explicit int8 type when adding the _common cartesian-product column so that zero-row tables don't produce a null-typed column that Polars rejects on join.
…unused pytest import (ITL-563)
… (ITL-563) Replace the hand-rolled empty-branch logic in SideEffectPodStream.as_table() and SideEffectNode.as_table() with arrow_utils.make_empty_table(), which delegates to python_schema_to_arrow_schema and preserves nullable=False for plain types. Also move arrow_utils import to module level per project convention. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…ty (ITL-563) Replace hand-rolled pa.field() construction in the empty-cache branch of DerivedSource._get_stream() with arrow_utils.make_empty_table(), which delegates to python_schema_to_arrow_schema and correctly sets nullable=False for required (non-Optional) fields. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…(ITL-563) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
There was a problem hiding this comment.
Pull request overview
This PR fixes a correctness issue where zero-row PyArrow tables were being materialized with all fields marked nullable=True, causing downstream schema mismatches (notably in Join) and aborting pipelines even under error_policy="continue". It centralizes empty-table creation into a single utility that round-trips through the existing Python↔Arrow schema conversion logic to preserve per-field nullability.
Changes:
- Added
arrow_utils.make_empty_table(python_schema, type_converter)to build schema-correct zero-row tables (preserving required vs optional nullability). - Replaced multiple empty-table construction sites to use
make_empty_table, and fixedJoin.static_processto avoid Polars rejecting null-typed join-key columns on zero-row joins. - Added unit + integration + regression tests to lock in nullability preservation across orchestrator/materialization, operators, side effects, and derived sources; updated the design-issues log entry.
Reviewed changes
Copilot reviewed 14 out of 14 changed files in this pull request and generated 3 comments.
Show a summary per file
| File | Description |
|---|---|
| src/orcapod/utils/arrow_utils.py | Adds make_empty_table utility that preserves Arrow field nullability from Python schema. |
| src/orcapod/pipeline/sync_orchestrator.py | Uses make_empty_table in _materialize_as_stream empty-buffer path. |
| src/orcapod/core/nodes/operator_node.py | Uses make_empty_table in _make_empty_table to preserve nullability. |
| src/orcapod/side_effects.py | Uses make_empty_table for empty as_table() in side-effect stream/node paths. |
| src/orcapod/core/sources/derived_source.py | Uses make_empty_table when derived-source cache is empty. |
| src/orcapod/core/operators/join.py | Forces _common join key to int8 to avoid null-typed columns on zero-row joins. |
| tests/test_utils/test_arrow_utils.py | Adds unit tests for make_empty_table required/optional/mixed/round-trip behavior. |
| tests/test_pipeline/test_error_policy_continue.py | Adds integration regression for ITL-563 topology (continue + failing function feeding Join). |
| tests/test_core/nodes/test_function_node_iteration.py | Strengthens existing test to assert nullability matches between empty and non-empty tables. |
| tests/test_core/side_effect_function/test_side_effect_function_pod.py | Adds regression tests for side-effect empty-table nullability. |
| tests/test_core/sources/test_derived_source.py | Adds regression test for derived-source empty-cache nullability. |
| DESIGN_ISSUES.md | Marks CC1 as resolved and documents the fix + coverage. |
| superpowers/specs/2026-07-22-empty-table-nullability-design.md | Adds design spec for the fix and its scope/testing plan. |
| superpowers/plans/2026-07-22-empty-table-nullability.md | Adds implementation plan for the fix and test sequencing. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| def my_fn(value: int, ctx: InvocationContext) -> None: | ||
| return str(value) |
There was a problem hiding this comment.
Fixed in commit 6b16ee7. Replaced return str(value) with pass — side-effect functions have no meaningful return value, so the body now correctly matches the -> None annotation.
| def my_fn(value: int, ctx: InvocationContext) -> None: | ||
| return str(value) |
There was a problem hiding this comment.
Fixed in commit 6b16ee7. Same change as the companion test — replaced return str(value) with pass so the function body matches the -> None annotation.
…_ISSUES class names (ITL-563) - Remove `return str(value)` from two `-> None` side-effect test functions in TestSideEffectEmptyTableNullability; side-effect functions must return None - Update CC1 entry in DESIGN_ISSUES.md to use the actual class names (SideEffectPodStream/SideEffectNode) instead of the old plan names Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Codecov Report✅ All modified and coverable lines are covered by tests. 📢 Thoughts on this report? Let us know! |
Review round summary (commit 6b16ee7)Three issues addressed:
|
…ag-nullability-and-abort
Summary
arrow_utils.make_empty_table(python_schema, type_converter)— a single utility that builds zero-row PyArrow tables usingpython_schema_to_arrow_schema, preservingnullable=Falsefor required fields andnullable=TrueforT | Nonefieldspa.table(dict_of_arrays)(which silently marks every fieldnullable=True):sync_orchestrator._materialize_as_stream,operator_node._make_empty_table,SideEffectPodStream.as_table,SideEffectNode.as_table,DerivedSource._get_streamJoin.static_processrevealed by the integration test (Polars rejects null-typed_commoncolumn when joining zero-row tables)Root cause
When a function fails under
error_policy="continue", its empty output buffer was materialized viapa.table(dict_of_arrays). PyArrow marks every field in such tablesnullable=Trueregardless of the declared schema. A downstreamJointhen sawstron one input andstr | Noneon the other, raisedInputValidationError, and aborted the entire pipeline — bypassing thecontinuepolicy.Test plan
tests/test_utils/test_arrow_utils.py::TestMakeEmptyTable— 4 unit tests: required fields →nullable=False, optional →nullable=True, mixed, round-trip throughArrowTableStream.output_schema()tests/test_pipeline/test_error_policy_continue.py— integration test reproducing the exact issue topology (source → failing_function → Join ← sourceundererror_policy="continue"); second test directly asserts_materialize_as_streamschematests/test_core/nodes/test_function_node_iteration.py— strengthenedtest_as_table_empty_schema_matches_non_empty_schemawith nullable assertiontests/test_core/side_effect_function/test_side_effect_function_pod.py— regression tests forSideEffectPodStreamandSideEffectNodeempty table nullabilitytests/test_core/sources/test_derived_source.py— regression test forDerivedSourceempty cache nullabilityCloses ITL-563
🤖 Generated with Claude Code