unlock unlimited code runs for $79, limited time →
NOSSLETTER.techLIVE
back to OSSpedia
api-designtesting-strategydata-structures

GH-50801: [Python] Expose the record_batch_reader_source Acero node (RecordBatchReaderSourceNodeOptions)

Project: apache/arrow | Language: C++ | Concepts: api-design, testing-strategy, data-structures | Difficulty: mid
Link: https://github.com/apache/arrow/pull/50802

The problem

The Acero execution engine in Apache Arrow did not expose the record_batch_reader_source node to Python. This meant users could not construct execution plans in Python that stream data from a RecordBatchReader, limiting flexibility for streaming and generator-backed data sources, especially for use cases like hash joins where streaming the probe side is more efficient.

Why it's non-trivial

A naive approach might try to wrap a RecordBatchReader directly, but this would not integrate with the Acero execution plan API, nor allow the use of this node as a first-class source in plan declarations. Proper exposure requires careful API design, C++/Python bindings, and robust testing to ensure correctness and Pythonic error handling.

The approach

1. Expose the node in the Python API documentation and import path:

+   RecordBatchReaderSourceNodeOptions

This line adds the new options class to the public API docs, making it discoverable.

+        RecordBatchReaderSourceNodeOptions,

This line imports the new class in the main Python module, so users can access it directly.


2. Cython bindings for the C++ class:

+cdef class _RecordBatchReaderSourceNodeOptions(ExecNodeOptions):
+
+    def _set_options(self, RecordBatchReader reader):
+        self.wrapped.reset(
+            new CRecordBatchReaderSourceNodeOptions(reader.reader)
+        )

This defines a Cython class that wraps the C++ RecordBatchReaderSourceNodeOptions, allowing a Python RecordBatchReader to be passed in and properly wrapped for use in execution plans.


3. Python-facing options class:

+class RecordBatchReaderSourceNodeOptions(_RecordBatchReaderSourceNodeOptions):
+    """
+    A Source node which streams data from a RecordBatchReader.
+
+    This is the option class for the "record_batch_reader_source" node
+    factory.
+
+    Parameters
+    ----------
+    reader : pyarrow.RecordBatchReader
+        The reader which acts as the data source.
+    """
+
+    def __init__(self, RecordBatchReader reader not None):
+        self._set_options(reader)

This exposes the node to Python users, with docstrings and parameter checks. It ensures only a RecordBatchReader can be passed.


4. Cython declaration for the C++ class:

+    cdef cppclass CRecordBatchReaderSourceNodeOptions "arrow::acero::RecordBatchReaderSourceNodeOptions"(CExecNodeOptions):
+        CRecordBatchReaderSourceNodeOptions(shared_ptr[CRecordBatchReader] reader)

This makes the C++ constructor available to Cython, enabling the Python bindings.


5. Comprehensive tests:

+def test_record_batch_reader_source():
+    table = pa.table({'a': [1, 2, 3], 'b': [4, 5, 6]})
+    reader = pa.RecordBatchReader.from_batches(
+        table.schema, table.to_batches(max_chunksize=2)
+    )
+    decl = Declaration(
+        "record_batch_reader_source", RecordBatchReaderSourceNodeOptions(reader)
+    )
+    result = decl.to_table()
+    assert result.equals(table)
+
+    # a reader can only be consumed once
+    decl = Declaration(
+        "record_batch_reader_source", RecordBatchReaderSourceNodeOptions(reader)
+    )
+    result = decl.to_table()
+    assert result.num_rows == 0
+
+    with pytest.raises(TypeError):
+        RecordBatchReaderSourceNodeOptions(table)
+
+    with pytest.raises(TypeError):
+        RecordBatchReaderSourceNodeOptions(None)

This test checks basic usage, single-consumption semantics, and type errors.

+def test_record_batch_reader_source_lazy_generator():
+    # the reader can be backed by a Python generator, which is only
+    # consumed (from an I/O thread) while the plan executes
+    ...
+    def gen():
+        for i, batch in enumerate(batches):
+            consumed.append(i)
+            yield batch
+    ...
+    assert consumed == []
+    result = decl.to_table()
+    assert consumed == list(range(len(batches)))
+    assert result.sort_by("a").equals(table.slice(5))

This test ensures lazy consumption of generator-backed readers.

+def test_record_batch_reader_source_generator_error():
+    # an error raised by the generator propagates to the plan execution
+    ...
+    def gen():
+        yield pa.record_batch([pa.array([1, 2, 3])], schema=schema)
+        raise ValueError("error in generator")
+    ...
+    with pytest.raises(ValueError, match="error in generator"):
+        _ = decl.to_table()

This verifies that generator errors propagate correctly.

+def test_record_batch_reader_source_hash_join_probe():
+    # streaming reader as the probe side of a hash join
+    ...
+    joined = Declaration("hashjoin", options=join_opts,
+                         inputs=[left_source, right_source])
+    result = joined.to_table()
+    expected = pa.table({
+        'key': [2, 3, 4], 'a': ["b", "c", "d"], 'b': ["p", "q", "r"]
+    })
+    assert result.sort_by("key").equals(expected)

This test demonstrates the main motivating use case: streaming the probe side of a hash join.

From the review thread

No notable review discussion on this PR.

Takeaways

  • When exposing new functionality to a language binding, provide thorough tests that cover both typical and edge-case usage, including error propagation.
  • API design for streaming data should consider consumption semantics (e.g., single-use readers) and integrate smoothly with existing plan/declaration mechanisms.
  • Properly document and expose new options in both code and user-facing documentation to ensure discoverability and correct usage.