Skip to content

Commit efb23fd

Browse files
committed
Fix DataFusion pushdown filters by using factory pattern for streams
The previous implementation stored a single Arrow stream that could only be consumed once, causing subsequent queries on the same table to return empty results. This broke filters and aggregations. Changes: - Modify Rust PyArrowStreamPartition to accept a factory function instead of a stream object. The factory is called on each execute() to create a fresh stream, allowing multiple queries on the same table. - Update LazyArrowStreamTable to take a factory and schema instead of consuming a stream directly. - Update Python read_xarray_table to create a factory function that produces fresh XarrayRecordBatchReader instances. - Update tests to use the new factory-based API via read_xarray_table. This enables proper lazy evaluation while supporting multiple queries on registered tables, fixing the failing filter and aggregation tests.
1 parent a875edb commit efb23fd

3 files changed

Lines changed: 132 additions & 153 deletions

File tree

src/lib.rs

Lines changed: 60 additions & 63 deletions
Original file line numberDiff line numberDiff line change
@@ -29,31 +29,30 @@ use pyo3::prelude::*;
2929
use pyo3::types::PyCapsule;
3030
use tokio::runtime::Handle;
3131

32-
/// A partition stream that wraps a Python object implementing `__arrow_c_stream__`.
32+
/// A partition stream that wraps a Python factory function that creates streams.
3333
///
34-
/// The stream is consumed lazily - only when `execute()` is called during query execution.
34+
/// The factory is called lazily on each `execute()` invocation, allowing
35+
/// the same table to be queried multiple times.
3536
struct PyArrowStreamPartition {
3637
schema: SchemaRef,
37-
/// The Python object, wrapped in Option so it can be taken (consumed) exactly once.
38-
/// We use std::sync::Mutex for Send + Sync.
39-
py_stream: std::sync::Mutex<Option<Py<PyAny>>>,
38+
/// A Python callable (factory) that returns a fresh stream implementing `__arrow_c_stream__`.
39+
/// Called on each execute() to create a new stream.
40+
stream_factory: Py<PyAny>,
4041
}
4142

4243
impl PyArrowStreamPartition {
43-
fn new(py_obj: Py<PyAny>, schema: SchemaRef) -> Self {
44+
fn new(stream_factory: Py<PyAny>, schema: SchemaRef) -> Self {
4445
Self {
4546
schema,
46-
py_stream: std::sync::Mutex::new(Some(py_obj)),
47+
stream_factory,
4748
}
4849
}
4950
}
5051

5152
impl Debug for PyArrowStreamPartition {
5253
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
53-
let consumed = self.py_stream.lock().unwrap().is_none();
5454
f.debug_struct("PyArrowStreamPartition")
5555
.field("schema", &self.schema)
56-
.field("consumed", &consumed)
5756
.finish()
5857
}
5958
}
@@ -64,14 +63,14 @@ impl PartitionStream for PyArrowStreamPartition {
6463
}
6564

6665
fn execute(&self, _ctx: Arc<TaskContext>) -> SendableRecordBatchStream {
67-
// Take the Python object (can only be done once)
68-
let py_obj = self.py_stream.lock().unwrap().take();
66+
// Call the factory to get a fresh stream for this execution
67+
let batches: Vec<RecordBatch> = Python::with_gil(|py| {
68+
// Call the factory to get a fresh stream
69+
let stream_result = self.stream_factory.call0(py);
6970

70-
let batches: Vec<RecordBatch> = match py_obj {
71-
Some(obj) => {
72-
// Acquire the GIL and consume the stream
73-
Python::with_gil(|py| {
74-
let bound = obj.bind(py);
71+
match stream_result {
72+
Ok(stream_obj) => {
73+
let bound = stream_obj.bind(py);
7574

7675
match ArrowArrayStreamReader::from_pyarrow_bound(bound) {
7776
Ok(reader) => {
@@ -93,13 +92,13 @@ impl PartitionStream for PyArrowStreamPartition {
9392
vec![]
9493
}
9594
}
96-
})
95+
}
96+
Err(e) => {
97+
eprintln!("Warning: Failed to call stream factory: {e}");
98+
vec![]
99+
}
97100
}
98-
None => {
99-
// Stream already consumed, return empty
100-
vec![]
101-
}
102-
};
101+
});
103102

104103
Box::pin(
105104
MemoryStream::try_new(batches, Arc::clone(&self.schema), None)
@@ -108,31 +107,40 @@ impl PartitionStream for PyArrowStreamPartition {
108107
}
109108
}
110109

111-
/// A lazy table provider that wraps a Python Arrow stream.
110+
/// A lazy table provider that wraps a Python stream factory.
112111
///
113112
/// This class implements the `__datafusion_table_provider__` protocol, allowing
114113
/// it to be registered with DataFusion's `SessionContext.register_table()`.
115114
///
116115
/// Data is NOT read until query execution time - this enables true lazy evaluation.
116+
/// The factory function is called on each query execution to create a fresh stream,
117+
/// allowing the same table to be queried multiple times.
117118
///
118119
/// # Example
119120
///
120121
/// ```python
121122
/// from datafusion import SessionContext
122123
/// from xarray_sql import LazyArrowStreamTable, XarrayRecordBatchReader
123124
///
124-
/// # Create a lazy reader (implements __arrow_c_stream__)
125-
/// reader = XarrayRecordBatchReader(ds, chunks={'time': 240})
125+
/// # Create a factory that produces lazy readers
126+
/// def make_reader():
127+
/// return XarrayRecordBatchReader(ds, chunks={'time': 240})
128+
///
129+
/// # Get schema from a sample reader
130+
/// sample = make_reader()
131+
/// schema = sample.schema
126132
///
127-
/// # Wrap in lazy table - NO DATA LOADED
128-
/// table = LazyArrowStreamTable(reader)
133+
/// # Wrap factory in lazy table - NO DATA LOADED
134+
/// table = LazyArrowStreamTable(make_reader, schema)
129135
///
130136
/// # Register with DataFusion - STILL NO DATA LOADED
131137
/// ctx = SessionContext()
132138
/// ctx.register_table("air", table)
133139
///
134140
/// # Data only loaded HERE during collect()
141+
/// # Each query creates a fresh stream via the factory
135142
/// result = ctx.sql("SELECT AVG(air) FROM air").collect()
143+
/// result2 = ctx.sql("SELECT * FROM air LIMIT 10").collect() # Works!
136144
/// ```
137145
#[pyclass(name = "LazyArrowStreamTable")]
138146
struct LazyArrowStreamTable {
@@ -142,29 +150,39 @@ struct LazyArrowStreamTable {
142150

143151
#[pymethods]
144152
impl LazyArrowStreamTable {
145-
/// Create a new LazyArrowStreamTable from a Python object implementing `__arrow_c_stream__`.
153+
/// Create a new LazyArrowStreamTable from a stream factory function.
146154
///
147155
/// Args:
148-
/// stream: A Python object implementing the Arrow PyCapsule interface (`__arrow_c_stream__`).
149-
/// This includes `pyarrow.RecordBatchReader`, `XarrayRecordBatchReader`, etc.
156+
/// stream_factory: A callable that returns a Python object implementing
157+
/// the Arrow PyCapsule interface (`__arrow_c_stream__`).
158+
/// Called on each query execution to create a fresh stream.
159+
/// schema: A PyArrow Schema for the table. Required since the factory
160+
/// hasn't been called yet.
150161
///
151162
/// Raises:
152-
/// TypeError: If the object does not implement `__arrow_c_stream__`.
163+
/// TypeError: If the schema is not a valid PyArrow Schema.
153164
#[new]
154-
fn new(stream: &Bound<'_, PyAny>) -> PyResult<Self> {
155-
// Get the schema via the .schema attribute WITHOUT consuming the stream
156-
// This is important because calling __arrow_c_stream__ would consume the stream
157-
let schema = get_schema_from_stream(stream)?;
165+
fn new(stream_factory: &Bound<'_, PyAny>, schema: &Bound<'_, PyAny>) -> PyResult<Self> {
166+
// Convert the PyArrow schema to Arrow schema
167+
use arrow::datatypes::Schema;
168+
use arrow::pyarrow::FromPyArrow;
169+
170+
let arrow_schema = Schema::from_pyarrow_bound(schema).map_err(|e| {
171+
pyo3::exceptions::PyTypeError::new_err(format!("Failed to convert schema: {e}"))
172+
})?;
173+
let schema_ref = Arc::new(arrow_schema);
158174

159-
// Create the partition stream with the Python object
160-
let partition = PyArrowStreamPartition::new(stream.clone().unbind(), schema.clone());
175+
// Create the partition stream with the factory
176+
let partition =
177+
PyArrowStreamPartition::new(stream_factory.clone().unbind(), schema_ref.clone());
161178

162179
// Create the StreamingTable
163-
let table = StreamingTable::try_new(schema, vec![Arc::new(partition)]).map_err(|e| {
164-
pyo3::exceptions::PyRuntimeError::new_err(format!(
165-
"Failed to create StreamingTable: {e}"
166-
))
167-
})?;
180+
let table =
181+
StreamingTable::try_new(schema_ref, vec![Arc::new(partition)]).map_err(|e| {
182+
pyo3::exceptions::PyRuntimeError::new_err(format!(
183+
"Failed to create StreamingTable: {e}"
184+
))
185+
})?;
168186

169187
Ok(Self {
170188
table: Arc::new(table),
@@ -218,27 +236,6 @@ impl LazyArrowStreamTable {
218236
}
219237
}
220238

221-
/// Get schema from a Python object that has a schema attribute.
222-
///
223-
/// This extracts the schema WITHOUT consuming the stream, which is
224-
/// important for lazy evaluation.
225-
fn get_schema_from_stream(stream: &Bound<'_, PyAny>) -> PyResult<SchemaRef> {
226-
use arrow::datatypes::Schema;
227-
use arrow::pyarrow::FromPyArrow;
228-
229-
let py_schema = stream.getattr("schema").map_err(|e| {
230-
pyo3::exceptions::PyTypeError::new_err(format!(
231-
"Object must have a 'schema' attribute (e.g., RecordBatchReader): {e}"
232-
))
233-
})?;
234-
235-
let schema = Schema::from_pyarrow_bound(&py_schema).map_err(|e| {
236-
pyo3::exceptions::PyTypeError::new_err(format!("Failed to convert schema: {e}"))
237-
})?;
238-
239-
Ok(Arc::new(schema))
240-
}
241-
242239
/// Python module initialization
243240
#[pymodule]
244241
fn _native(m: &Bound<'_, PyModule>) -> PyResult<()> {

xarray_sql/reader.py

Lines changed: 20 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -186,18 +186,23 @@ def read_xarray_lazy(
186186

187187

188188
def read_xarray_table(
189-
ds: xr.Dataset, chunks: Chunks = None
189+
ds: xr.Dataset,
190+
chunks: Chunks = None,
191+
*,
192+
_iteration_callback: t.Optional[t.Callable[[Block], None]] = None,
190193
) -> "LazyArrowStreamTable":
191194
"""Create a lazy DataFusion table from an xarray Dataset.
192195
193196
This is the simplest way to register xarray data with DataFusion.
194197
Data is only read when queries are executed (during collect()),
195-
not during registration.
198+
not during registration. The table can be queried multiple times.
196199
197200
Args:
198201
ds: An xarray Dataset. All data_vars must share the same dimensions.
199202
chunks: Xarray-like chunks specification. If not provided, uses
200203
the Dataset's existing chunks.
204+
_iteration_callback: Internal callback for testing. Called with
205+
each block dict just before it's converted to Arrow.
201206
202207
Returns:
203208
A LazyArrowStreamTable ready for registration with DataFusion.
@@ -215,8 +220,19 @@ def read_xarray_table(
215220
>>>
216221
>>> # Data is only read here, during collect()
217222
>>> result = ctx.sql('SELECT AVG(air) FROM air').collect()
223+
>>> # Can query again - each query creates a fresh stream
224+
>>> result2 = ctx.sql('SELECT * FROM air LIMIT 10').collect()
218225
"""
219226
from ._native import LazyArrowStreamTable
220227

221-
reader = XarrayRecordBatchReader(ds, chunks)
222-
return LazyArrowStreamTable(reader)
228+
# Get schema from dataset without creating a stream
229+
schema = _parse_schema(ds)
230+
231+
# Create a factory function that produces fresh streams on each call
232+
def make_stream() -> pa.RecordBatchReader:
233+
reader = XarrayRecordBatchReader(
234+
ds, chunks, _iteration_callback=_iteration_callback
235+
)
236+
return pa.RecordBatchReader.from_stream(reader)
237+
238+
return LazyArrowStreamTable(make_stream, schema)

0 commit comments

Comments
 (0)