Skip to content

Commit 2de1342

Browse files
alxmrsclaude
andauthored
Column Projection Pushdown (#119)
Only access/pivot the columns from the Xarray dataset that are actually used by the query. For a `SELECT temperature FROM ds` query on a 250-variable ERA5 dataset, this reduces per-partition data loading from ~38 GB to ~1.5 GB (the temperature array plus coordinate columns). Fixes #103, Fixes #138. --------- Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent f685712 commit 2de1342

4 files changed

Lines changed: 353 additions & 50 deletions

File tree

src/lib.rs

Lines changed: 155 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -50,7 +50,7 @@ use std::fmt::Debug;
5050
use std::sync::Arc;
5151

5252
use arrow::array::RecordBatch;
53-
use arrow::datatypes::SchemaRef;
53+
use arrow::datatypes::{Schema, SchemaRef};
5454
use arrow::pyarrow::FromPyArrow;
5555
use async_stream::try_stream;
5656
use async_trait::async_trait;
@@ -68,7 +68,7 @@ use datafusion::physical_plan::streaming::PartitionStream;
6868
use datafusion::physical_plan::{ExecutionPlan, SendableRecordBatchStream};
6969
use datafusion_ffi::table_provider::FFI_TableProvider;
7070
use pyo3::prelude::*;
71-
use pyo3::types::PyCapsule;
71+
use pyo3::types::{PyCapsule, PyList};
7272
use tokio::runtime::Handle;
7373

7474
// ============================================================================
@@ -157,16 +157,18 @@ impl PartitionMetadata {
157157
/// `TableProvider::supports_filters_pushdown` and partition pruning in `scan()`.
158158
struct PrunableStreamingTable {
159159
schema: SchemaRef,
160-
/// Partition streams paired with their coordinate range metadata
161-
partitions: Vec<(Arc<dyn PartitionStream>, PartitionMetadata)>,
160+
/// Partition streams paired with their coordinate range metadata.
161+
/// Stored behind the `ProjectableStream` trait so `PrunableStreamingTable`
162+
/// is not coupled to `PyArrowStreamPartition`.
163+
partitions: Vec<(Arc<dyn ProjectableStream>, PartitionMetadata)>,
162164
/// Set of column names that are dimension columns (eligible for pruning)
163165
dimension_columns: HashSet<String>,
164166
}
165167

166168
impl PrunableStreamingTable {
167169
fn new(
168170
schema: SchemaRef,
169-
partitions: Vec<(Arc<dyn PartitionStream>, PartitionMetadata)>,
171+
partitions: Vec<(Arc<dyn ProjectableStream>, PartitionMetadata)>,
170172
) -> Self {
171173
// Collect dimension column names from the first partition that has
172174
// non-empty metadata. All partitions share the same dimension names,
@@ -435,6 +437,24 @@ impl PrunableStreamingTable {
435437
}
436438
}
437439

440+
/// Extension trait for partition streams that support column projection.
441+
///
442+
/// Implemented by `PyArrowStreamPartition` so that `PrunableStreamingTable`
443+
/// can push projections to Python factories without coupling to the concrete type.
444+
/// Any new stream implementation (e.g. for non-Python backends) can implement this
445+
/// trait and be used with `PrunableStreamingTable` directly.
446+
trait ProjectableStream: PartitionStream + Debug {
447+
/// Return a new stream that emits only the specified columns.
448+
fn clone_with_projection(
449+
&self,
450+
projection: Arc<[String]>,
451+
projected_schema: SchemaRef,
452+
) -> Arc<dyn PartitionStream>;
453+
454+
/// Clone this stream as a generic `PartitionStream` Arc.
455+
fn clone_as_stream(&self) -> Arc<dyn PartitionStream>;
456+
}
457+
438458
/// Flip a comparison operator (for when literal is on left side).
439459
fn flip_operator(op: &Operator) -> Operator {
440460
match op {
@@ -545,49 +565,137 @@ impl TableProvider for PrunableStreamingTable {
545565
// Prune partitions based on filters
546566
let included_indices = self.prune_partitions(filters);
547567

548-
// Collect only the included partition streams
549-
let included_partitions: Vec<Arc<dyn PartitionStream>> = included_indices
550-
.iter()
551-
.map(|&idx| Arc::clone(&self.partitions[idx].0))
552-
.collect();
553-
554-
// Handle empty case - create an empty streaming table
555-
if included_partitions.is_empty() {
556-
// Create a streaming table with no partitions
557-
// DataFusion will return empty result
568+
// Handle empty case — all partitions pruned, return empty plan
569+
if included_indices.is_empty() {
558570
let empty_table = StreamingTable::try_new(Arc::clone(&self.schema), vec![])?;
559571
return empty_table.scan(state, projection, filters, limit).await;
560572
}
561573

562-
// Create StreamingTable with the pruned partitions
563-
let streaming = StreamingTable::try_new(Arc::clone(&self.schema), included_partitions)?;
574+
// Determine whether to push projection down to the Python factory.
575+
//
576+
// We push when the projection includes at least one data variable
577+
// (non-dimension column), because xarray can selectively load only
578+
// the requested data arrays while dimension coordinates are always
579+
// available via xarray's coordinate system.
580+
//
581+
// We do NOT push when:
582+
// - projection is None (load everything — factory receives None)
583+
// - projection is Some([]) (COUNT(*) — let StreamingTable handle)
584+
// - projection contains only dimension columns (ds.to_dataframe()
585+
// needs at least one data variable; dimensions are always loaded)
586+
let push_projection = match projection {
587+
Some(indices) if !indices.is_empty() => indices
588+
.iter()
589+
.any(|&i| !self.dimension_columns.contains(self.schema.field(i).name())),
590+
_ => false,
591+
};
564592

565-
// Delegate to StreamingTable for actual execution.
566-
// We report Inexact, so DataFusion still applies row-level filtering.
567-
streaming.scan(state, projection, filters, limit).await
593+
if push_projection {
594+
let indices = projection.unwrap();
595+
596+
// Build the projected schema (only the requested fields)
597+
let proj_fields: Vec<_> = indices
598+
.iter()
599+
.map(|&i| self.schema.field(i).clone())
600+
.collect();
601+
let projected_schema = Arc::new(Schema::new(proj_fields));
602+
603+
// Collect the requested column names to send to the factory.
604+
// Stored in an Arc so each clone_with_projection call shares the
605+
// same allocation via an atomic refcount increment (no N Vec copies).
606+
let proj_col_names: Arc<[String]> = indices
607+
.iter()
608+
.map(|&i| self.schema.field(i).name().to_string())
609+
.collect::<Vec<_>>()
610+
.into();
611+
612+
// Clone each pruned partition with the projection baked in.
613+
// The factory will receive proj_col_names and load only those vars.
614+
let projected_partitions: Vec<Arc<dyn PartitionStream>> = included_indices
615+
.iter()
616+
.map(|&idx| {
617+
self.partitions[idx].0.clone_with_projection(
618+
Arc::clone(&proj_col_names),
619+
Arc::clone(&projected_schema),
620+
)
621+
})
622+
.collect();
623+
624+
// StreamingTable already has the projected schema — pass None for
625+
// projection so it doesn't wrap the stream in a redundant ProjectionExec.
626+
let streaming = StreamingTable::try_new(projected_schema, projected_partitions)?;
627+
streaming.scan(state, None, filters, limit).await
628+
} else {
629+
// No projection pushdown — factory is called with None (loads all
630+
// columns). StreamingTable applies projection via ProjectionExec.
631+
let included_partitions: Vec<Arc<dyn PartitionStream>> = included_indices
632+
.iter()
633+
.map(|&idx| self.partitions[idx].0.clone_as_stream())
634+
.collect();
635+
let streaming = StreamingTable::try_new(Arc::clone(&self.schema), included_partitions)?;
636+
streaming.scan(state, projection, filters, limit).await
637+
}
568638
}
569639
}
570640

571641
/// A partition stream that wraps a Python factory function that creates streams.
572642
///
573643
/// The factory is called lazily on each `execute()` invocation, allowing
574644
/// the same table to be queried multiple times.
645+
///
646+
/// When `projection` is set, the factory is called with that list of column
647+
/// names so that xarray only loads the requested data variables rather than
648+
/// materializing every variable in the dataset.
575649
struct PyArrowStreamPartition {
576650
schema: SchemaRef,
577-
/// A Python callable (factory) that returns a fresh stream implementing `__arrow_c_stream__`.
578-
/// Called on each execute() to create a new stream.
579-
stream_factory: Py<PyAny>,
651+
/// A Python callable (factory) that returns a fresh stream.
652+
/// Signature: `make_stream(projection_names: Optional[List[str]]) -> RecordBatchReader`
653+
///
654+
/// Wrapped in `Arc` so `ProjectableStream::clone_with_projection` can share
655+
/// the same Python object across projected partitions without acquiring the
656+
/// GIL — only an atomic reference-count increment is needed.
657+
stream_factory: Arc<Py<PyAny>>,
658+
/// Column names to pass to the factory. `None` means load all columns.
659+
/// Stored as `Arc<[String]>` so multiple projected clones share one allocation.
660+
projection: Option<Arc<[String]>>,
580661
}
581662

582663
impl PyArrowStreamPartition {
583664
fn new(stream_factory: Py<PyAny>, schema: SchemaRef) -> Self {
584665
Self {
585666
schema,
586-
stream_factory,
667+
stream_factory: Arc::new(stream_factory),
668+
projection: None,
587669
}
588670
}
589671
}
590672

673+
impl ProjectableStream for PyArrowStreamPartition {
674+
/// Return a new partition that emits only the given columns.
675+
///
676+
/// Clones the factory `Arc` (atomic refcount increment, no GIL) so the
677+
/// same Python callable is shared across all projected partitions.
678+
fn clone_with_projection(
679+
&self,
680+
projection: Arc<[String]>,
681+
projected_schema: SchemaRef,
682+
) -> Arc<dyn PartitionStream> {
683+
Arc::new(Self {
684+
schema: projected_schema,
685+
stream_factory: Arc::clone(&self.stream_factory),
686+
projection: Some(projection),
687+
})
688+
}
689+
690+
fn clone_as_stream(&self) -> Arc<dyn PartitionStream> {
691+
Arc::new(Self {
692+
schema: Arc::clone(&self.schema),
693+
stream_factory: Arc::clone(&self.stream_factory),
694+
projection: self.projection.clone(),
695+
})
696+
}
697+
}
698+
591699
impl Debug for PyArrowStreamPartition {
592700
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
593701
f.debug_struct("PyArrowStreamPartition")
@@ -604,8 +712,9 @@ impl PartitionStream for PyArrowStreamPartition {
604712
fn execute(&self, _ctx: Arc<TaskContext>) -> SendableRecordBatchStream {
605713
let schema = Arc::clone(&self.schema);
606714

607-
// Clone the factory with the GIL held
608-
let factory = Python::attach(|py| self.stream_factory.clone_ref(py));
715+
// Clone the factory Arc (no GIL needed) and the projection list.
716+
let factory = Arc::clone(&self.stream_factory);
717+
let projection = self.projection.clone();
609718

610719
// Create a lazy stream using try_stream! macro.
611720
// The GIL is acquired only for the duration of each Python call
@@ -614,9 +723,22 @@ impl PartitionStream for PyArrowStreamPartition {
614723
// executor a chance to poll other partition streams (which can then
615724
// acquire the GIL and make progress in parallel).
616725
let batch_stream = try_stream! {
617-
// Call factory to get the PyArrow RecordBatchReader
726+
// Call factory with the projection argument.
727+
// `projection` is either a Python list of column names or None
728+
// (load all columns). The factory always receives exactly one arg
729+
// so it can distinguish "no projection" from "empty projection".
618730
let reader: Py<PyAny> = Python::attach(|py| {
619-
factory.call0(py).map_err(|e| {
731+
let proj_arg = match &projection {
732+
Some(cols) => PyList::new(py, cols.iter().map(|s| s.as_str()))
733+
.map_err(|e| {
734+
DataFusionError::Execution(format!(
735+
"Failed to build projection list: {e}"
736+
))
737+
})?
738+
.into_any(),
739+
None => py.None().into_bound(py).into_any(),
740+
};
741+
factory.call1(py, (proj_arg,)).map_err(|e| {
620742
DataFusionError::Execution(format!("Failed to call stream factory: {e}"))
621743
})
622744
})?;
@@ -748,7 +870,9 @@ impl LazyArrowStreamTable {
748870
// eliminating the per-partition Python::attach() calls of the old
749871
// three-list approach. Python can release each block dict, factory
750872
// closure, and metadata dict as soon as Rust has ingested them.
751-
let mut partition_list: Vec<(Arc<dyn PartitionStream>, PartitionMetadata)> = Vec::new();
873+
// Stored as Arc<dyn ProjectableStream> so PrunableStreamingTable
874+
// is decoupled from PyArrowStreamPartition.
875+
let mut partition_list: Vec<(Arc<dyn ProjectableStream>, PartitionMetadata)> = Vec::new();
752876
for item_result in partitions.try_iter()? {
753877
let item = item_result?;
754878
let (factory_obj, meta_obj): (Py<PyAny>, Py<PyAny>) = item.extract().map_err(|e| {
@@ -757,8 +881,8 @@ impl LazyArrowStreamTable {
757881
))
758882
})?;
759883
let meta = convert_python_metadata_from_bound(meta_obj.bind(partitions.py()))?;
760-
let partition = Arc::new(PyArrowStreamPartition::new(factory_obj, schema_ref.clone()))
761-
as Arc<dyn PartitionStream>;
884+
let partition: Arc<dyn ProjectableStream> =
885+
Arc::new(PyArrowStreamPartition::new(factory_obj, schema_ref.clone()));
762886
partition_list.push((partition, meta));
763887
}
764888

xarray_sql/df.py

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -324,11 +324,11 @@ def _block_metadata(coord_arrays: dict, block: Block) -> PartitionBounds:
324324
for dim, slc in block.items():
325325
coord_values = coord_arrays[str(dim)][slc]
326326
if len(coord_values) > 0:
327-
first, last = coord_values[0], coord_values[-1]
328-
if first <= last:
329-
min_val, max_val = first, last
330-
else:
331-
min_val, max_val = last, first
327+
# Use actual min/max rather than first/last so that non-monotonic
328+
# coordinate axes (e.g. descending latitude 90→-90) are handled
329+
# correctly. np.min/max work for both numeric and datetime64 arrays.
330+
min_val = coord_values.min()
331+
max_val = coord_values.max()
332332

333333
if isinstance(min_val, (np.datetime64, pd.Timestamp)):
334334
min_val = int(pd.Timestamp(min_val).value)

xarray_sql/reader.py

Lines changed: 37 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -63,7 +63,9 @@ def __init__(
6363
chunks: Chunks = None,
6464
*,
6565
batch_size: int = DEFAULT_BATCH_SIZE,
66-
_iteration_callback: Callable[[Block], None] | None = None,
66+
_iteration_callback: (
67+
Callable[[Block, list[str] | None], None] | None
68+
) = None,
6769
):
6870
"""Initialize the lazy reader.
6971
@@ -105,9 +107,10 @@ def _generate_batches(self) -> Iterator[pa.RecordBatch]:
105107
emitted as one or more RecordBatches of at most self._batch_size rows.
106108
"""
107109
for block in block_slices(self._ds, self._chunks):
108-
# Call the iteration callback if provided (for testing)
110+
# Call the iteration callback if provided (for testing).
111+
# XarrayRecordBatchReader has no projection concept, so always passes None.
109112
if self._iteration_callback is not None:
110-
self._iteration_callback(block)
113+
self._iteration_callback(block, None)
111114

112115
yield from iter_record_batches(
113116
self._ds.isel(block), self._schema, self._batch_size
@@ -187,7 +190,9 @@ def read_xarray_table(
187190
chunks: Chunks = None,
188191
*,
189192
batch_size: int = DEFAULT_BATCH_SIZE,
190-
_iteration_callback: Callable[[Block], None] | None = None,
193+
_iteration_callback: (
194+
Callable[[Block, list[str] | None], None] | None
195+
) = None,
191196
) -> "LazyArrowStreamTable":
192197
"""Create a lazy DataFusion table from an xarray Dataset.
193198
@@ -248,14 +253,38 @@ def read_xarray_table(
248253
# Zarr-backed datasets (e.g. ARCO-ERA5 on GCS).
249254
coord_arrays = {str(dim): ds.coords[dim].values for dim in ds.dims}
250255

256+
# Determine which column names are data variables (not dimension coordinates).
257+
# Used by the factory to skip loading unrequested variables.
258+
data_var_names = set(ds.data_vars.keys())
259+
251260
def make_partition_factory(
252261
block: Block,
253-
) -> Callable[[], pa.RecordBatchReader]:
254-
def make_stream() -> pa.RecordBatchReader:
262+
) -> Callable[[list[str] | None], pa.RecordBatchReader]:
263+
def make_stream(
264+
projection_names: list[str] | None,
265+
) -> pa.RecordBatchReader:
255266
if _iteration_callback is not None:
256-
_iteration_callback(block)
267+
_iteration_callback(block, projection_names)
268+
269+
if projection_names is not None:
270+
# Restrict to the data variables mentioned in the projection.
271+
# Dimension coordinates come along automatically via coords.
272+
data_vars_needed = [c for c in projection_names if c in data_var_names]
273+
if data_vars_needed:
274+
ds_block = ds[data_vars_needed].isel(block)
275+
else:
276+
# Only dimension coords requested — drop all data vars to avoid
277+
# loading them unnecessarily (e.g. for queries like SELECT lat, lon).
278+
ds_block = ds[[]].isel(block)
279+
batch_schema = pa.schema(
280+
[schema.field(name) for name in projection_names]
281+
)
282+
else:
283+
ds_block = ds.isel(block)
284+
batch_schema = schema
285+
257286
return pa.RecordBatchReader.from_batches(
258-
schema, iter_record_batches(ds.isel(block), schema, batch_size)
287+
batch_schema, iter_record_batches(ds_block, batch_schema, batch_size)
259288
)
260289

261290
return make_stream

0 commit comments

Comments
 (0)