Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 4 additions & 3 deletions arro3-core/python/arro3/core/_record_batch_reader.pyi
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
from typing import Sequence
from collections.abc import Iterable

from ._record_batch import RecordBatch
from ._schema import Schema
Expand Down Expand Up @@ -53,13 +53,14 @@ class RecordBatchReader:
"""Construct this object from a bare Arrow PyCapsule"""
@classmethod
def from_batches(
cls, schema: ArrowSchemaExportable, batches: Sequence[ArrowArrayExportable]
cls, schema: ArrowSchemaExportable, batches: Iterable[ArrowArrayExportable]
) -> RecordBatchReader:
"""Construct a new RecordBatchReader from existing data.

Args:
schema: The schema of the Arrow batches.
batches: The existing batches.
batches: The existing batches. Can be a list, generator, or any
iterable. Generators are consumed lazily.
Comment on lines +62 to +63

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

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

We should clarify; iterators are probably also consumed lazily.

"""
@classmethod
def from_stream(cls, data: ArrowStreamExportable) -> RecordBatchReader:
Expand Down
84 changes: 73 additions & 11 deletions pyo3-arrow/src/record_batch_reader.rs
Original file line number Diff line number Diff line change
@@ -1,12 +1,13 @@
use std::fmt::Display;
use std::sync::{Arc, Mutex};

use arrow_array::{ArrayRef, RecordBatchIterator, RecordBatchReader, StructArray};
use arrow_schema::{Field, SchemaRef};
use arrow_array::{ArrayRef, RecordBatch, RecordBatchIterator, RecordBatchReader, StructArray};
use arrow_schema::{ArrowError, Field, SchemaRef};
use pyo3::exceptions::{PyIOError, PyStopIteration, PyValueError};
use pyo3::intern;
use pyo3::prelude::*;
use pyo3::types::{PyCapsule, PyTuple, PyType};
use pyo3::Borrowed;

use crate::error::PyArrowResult;
use crate::export::{Arro3RecordBatch, Arro3Schema, Arro3Table};
Expand All @@ -19,6 +20,59 @@ use crate::input::AnyRecordBatch;
use crate::schema::display_schema;
use crate::{PyRecordBatch, PySchema, PyTable};

/// Input for `from_batches`: either a sequence (extracted eagerly) or an
/// arbitrary iterable (consumed lazily via GIL-acquiring iterator).
enum RecordBatchInput {
Sequence(Vec<PyRecordBatch>),
Iterator(Py<PyAny>),
Comment on lines +26 to +27

@kylebarron kylebarron Apr 15, 2026

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

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

It would be good to double-check here what pyo3 auto-converts to a sequence.

It'll convert anything with an __iter__ and __len__ to a Vec<T>?

Perhaps it makes sense to make a one-off test function just to validate how it handles inputs. We don't want it automatically materializing iterators to a sequence.

}

impl<'a, 'py> FromPyObject<'a, 'py> for RecordBatchInput {
type Error = PyErr;

fn extract(ob: Borrowed<'a, 'py, PyAny>) -> PyResult<Self> {
if let Ok(vec) = ob.extract::<Vec<PyRecordBatch>>() {
Ok(Self::Sequence(vec))
} else {
let iter = ob.call_method0(intern!(ob.py(), "__iter__"))?;

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

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

Here we should have a better error message. If __iter__ doesn't exist, we should print something like "RecordBatchInput requires a sequence or iterator of record batches"

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

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

By default the ? will give a somewhat opaque error message of just "__iter__ does not exist" or something like that.

Ok(Self::Iterator(iter.unbind()))
}
}
}

/// A RecordBatchReader that lazily pulls batches from a Python iterator.
///
/// Each call to `next()` acquires the GIL and calls `__next__` on the
/// underlying Python iterator.
struct PyIteratorRecordBatchReader {
schema: SchemaRef,
iter: Py<PyAny>,
}
Comment on lines +43 to +50

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

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

This is a performance regression for any existing case of a sequence of batches, because now it needs to acquire the GIL for every iteration

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

In a micro-benchmark, absolutely. In real world cases where record batches contain a meaningful amount of data, probably not. If you feel strongly, then I can probably special case Sequence from Iterable to win very microsecond back.

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

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

I think acquiring the GIL can have real-world performance overhead and I'd rather not introduce it for the existing approach where it doesn't need it.

You can define an enum like

enum PyRecordBatchReaderInput {
    Sequence(Vec<PyRecordBatch>)
    Iterator(PyRecordBatchIterator)
}

and then implement FromPyObject on that where first it checks if it's a sequence, and then extracts to the Sequence variant. And falls back to Iterator

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

No worries. Done.


impl Iterator for PyIteratorRecordBatchReader {
type Item = Result<RecordBatch, ArrowError>;

fn next(&mut self) -> Option<Self::Item> {
Python::attach(|py| {
let iter = self.iter.bind(py);
match iter.call_method0(intern!(py, "__next__")) {
Ok(obj) => match obj.extract::<PyRecordBatch>() {
Ok(batch) => Some(Ok(batch.into_inner())),
Err(e) => Some(Err(ArrowError::ExternalError(Box::new(e)))),
},
Err(e) if e.is_instance_of::<PyStopIteration>(py) => None,
Err(e) => Some(Err(ArrowError::ExternalError(Box::new(e)))),
}
})
}
}

impl RecordBatchReader for PyIteratorRecordBatchReader {
fn schema(&self) -> SchemaRef {
self.schema.clone()
}
}

/// A Python-facing Arrow record batch reader.
///
/// This is a wrapper around a [RecordBatchReader].
Expand Down Expand Up @@ -217,15 +271,23 @@ impl PyRecordBatchReader {
}

#[classmethod]
fn from_batches(_cls: &Bound<PyType>, schema: PySchema, batches: Vec<PyRecordBatch>) -> Self {
let batches = batches
.into_iter()
.map(|batch| batch.into_inner())
.collect::<Vec<_>>();
Self::new(Box::new(RecordBatchIterator::new(
batches.into_iter().map(Ok),
schema.into_inner(),
)))
fn from_batches(_cls: &Bound<PyType>, schema: PySchema, batches: RecordBatchInput) -> Self {
let schema = schema.into_inner();
match batches {
RecordBatchInput::Sequence(vec) => {
let batches = vec
.into_iter()
.map(|batch| batch.into_inner())
.collect::<Vec<_>>();
Self::new(Box::new(RecordBatchIterator::new(
batches.into_iter().map(Ok),
schema,
)))
}
RecordBatchInput::Iterator(iter) => {
Self::new(Box::new(PyIteratorRecordBatchReader { schema, iter }))
}
}
Comment on lines +277 to +290

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

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

Nit: I'd move this onto a private method on RecordBatchInput named into_record_batch_iterator which returns a Box<dyn RecordBatchIterator>.

Then this call site can stay clean

}

#[classmethod]
Expand Down
34 changes: 34 additions & 0 deletions tests/core/test_ffi.py
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,40 @@ def test_table_metadata_preserved():
assert pa_table_retour.schema.metadata == metadata


def test_record_batch_reader_from_batches_generator():
"""from_batches accepts a generator and consumes it lazily."""
a = pa.array([1, 2, 3], type=pa.int32())
table = Table.from_pydict({"a": a})
schema = table.schema
batches = table.to_batches()

consumed = []

def batch_gen():
for batch in batches:
consumed.append(True)
yield batch

gen = batch_gen()
reader = RecordBatchReader.from_batches(schema, gen)

# Generator not consumed yet
assert len(consumed) == 0

result = reader.read_all()
assert result.num_rows == 3
assert len(consumed) == 1 # consumed lazily


def test_record_batch_reader_from_batches_list():
"""from_batches still accepts a list (backwards compat)."""
a = pa.array([1, 2, 3], type=pa.int32())
table = Table.from_pydict({"a": a})
reader = RecordBatchReader.from_batches(table.schema, table.to_batches())
result = reader.read_all()
assert result.num_rows == 3


def test_record_batch_reader_metadata_preserved():
metadata = {b"hello": b"world"}
pa_table = pa.table({"a": [1, 2, 3]})
Expand Down
Loading