Skip to content

Commit 8378c93

Browse files
authored
Merge branch 'main' into feat/spatial-support
2 parents 02da72d + a1c5694 commit 8378c93

6 files changed

Lines changed: 313 additions & 12 deletions

File tree

‎CHANGELOG.md‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,10 @@
66

77
- Enabled the crate's `spatial` feature, so `DRAW spatial` map layers now render through the Vega-Lite writer instead of raising "Spatial visualization requires the 'spatial' feature to be enabled". Spatial layers require DuckDB's `spatial` extension at runtime: ggsql issues `LOAD spatial` automatically but never `INSTALL` it, so run `INSTALL spatial` once per machine (e.g. via `DuckDBReader.execute_sql()`; the download is cached under `~/.duckdb/extensions`). Works with WKB geometry columns and the built-in `ggsql:world` dataset, including named map projections such as `PROJECT TO equal_earth`.
88

9+
### Changed
10+
11+
- Data now crosses the Python↔Rust boundary via the zero-copy Arrow C Data Interface instead of Arrow IPC serialization, reducing memory copies and peak RSS for large results (on a 1M-row × 50-col register+read microbenchmark: ~5.7× faster, ~1 GB lower peak RSS). Set `GGSQL_ARROW_IPC=1` to restore the previous IPC behavior. The minimum pyarrow version is now 15.
12+
913
## 0.5.2
1014

1115
### Added

‎Cargo.toml‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@ crate-type = ["cdylib"]
1515

1616
[dependencies]
1717
pyo3 = { version = "0.26", features = ["extension-module", "abi3-py310"] }
18-
arrow = { version = "58", default-features = false, features = ["ipc"] }
18+
arrow = { version = "58", default-features = false, features = ["ipc", "ffi"] }
1919
ggsql = { version = "0.5.2", default-features = false, features = ["duckdb", "vegalite", "builtin-data", "svg", "pdf", "hep", "spatial"] }
2020

2121
[features]

‎pyproject.toml‎

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -17,14 +17,14 @@ classifiers = [
1717
dependencies = [
1818
"altair>=5.0",
1919
"narwhals>=2.15.0",
20-
"pyarrow>=14.0",
20+
"pyarrow>=15.0",
2121
]
2222

2323
[project.urls]
2424
Repository = "https://github.com/posit-dev/ggsql-python"
2525

2626
[project.optional-dependencies]
27-
test = ["pytest>=7.0", "duckdb>=1.0", "pyarrow>=14.0"]
27+
test = ["pytest>=7.0", "duckdb>=1.0", "pyarrow>=15.0"]
2828
dev = ["maturin>=1.4"]
2929

3030
[tool.maturin]
@@ -36,7 +36,7 @@ module-name = "ggsql._ggsql"
3636
dev = [
3737
"duckdb>=1.0",
3838
"maturin>=1.11.5",
39-
"pyarrow>=14.0",
39+
"pyarrow>=15.0",
4040
"pytest>=9.0.2",
4141
]
4242
docs = [

‎src/lib.rs‎

Lines changed: 184 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -3,9 +3,11 @@
33
#![allow(clippy::useless_conversion)]
44

55
use pyo3::prelude::*;
6-
use pyo3::types::{PyBytes, PyDict, PyList};
6+
use pyo3::types::{PyBytes, PyCapsule, PyDict, PyList};
77
use std::io::Cursor;
88

9+
use arrow::array::RecordBatchIterator;
10+
use arrow::ffi_stream::{ArrowArrayStreamReader, FFI_ArrowArrayStream};
911
use arrow::ipc::reader::StreamReader;
1012
use arrow::ipc::writer::StreamWriter;
1113

@@ -24,8 +26,19 @@ use ggsql::GgsqlError;
2426
// Helper Functions for DataFrame Conversion
2527
// ============================================================================
2628

27-
/// Convert a ggsql DataFrame to a Python pyarrow Table via Arrow IPC serialization
28-
fn df_to_py(py: Python<'_>, df: &DataFrame) -> PyResult<Py<PyAny>> {
29+
/// Whether to use the legacy Arrow IPC serialization path instead of the
30+
/// zero-copy Arrow C Data Interface path. Enabled by setting GGSQL_ARROW_IPC
31+
/// to any non-empty value other than "0".
32+
fn use_ipc_fallback() -> bool {
33+
match std::env::var_os("GGSQL_ARROW_IPC") {
34+
Some(v) => !v.is_empty() && v != "0",
35+
None => false,
36+
}
37+
}
38+
39+
/// Convert a ggsql DataFrame to a Python pyarrow Table via Arrow IPC
40+
/// serialization (legacy fallback; see GGSQL_ARROW_IPC)
41+
fn df_to_py_ipc(py: Python<'_>, df: &DataFrame) -> PyResult<Py<PyAny>> {
2942
let rb = df.inner();
3043
let mut buffer = Vec::new();
3144
{
@@ -58,8 +71,99 @@ fn df_to_py(py: Python<'_>, df: &DataFrame) -> PyResult<Py<PyAny>> {
5871
Ok(table.into())
5972
}
6073

61-
/// Convert a Python pyarrow Table to a ggsql DataFrame via Arrow IPC serialization
62-
fn py_to_df(py: Python<'_>, obj: &Bound<'_, PyAny>) -> PyResult<DataFrame> {
74+
/// Convert a ggsql DataFrame to a Python pyarrow Table.
75+
///
76+
/// Uses the zero-copy Arrow C Data Interface by default; set GGSQL_ARROW_IPC=1
77+
/// to fall back to Arrow IPC serialization.
78+
fn df_to_py(py: Python<'_>, df: &DataFrame) -> PyResult<Py<PyAny>> {
79+
if use_ipc_fallback() {
80+
df_to_py_ipc(py, df)
81+
} else {
82+
df_to_py_stream(py, df)
83+
}
84+
}
85+
86+
/// Convert a ggsql DataFrame to a Python pyarrow Table via the Arrow C Data
87+
/// Interface (zero-copy).
88+
fn df_to_py_stream(py: Python<'_>, df: &DataFrame) -> PyResult<Py<PyAny>> {
89+
let rb = df.inner().clone();
90+
let schema = rb.schema();
91+
let reader = RecordBatchIterator::new(std::iter::once(Ok(rb)), schema);
92+
let stream = FFI_ArrowArrayStream::new(Box::new(reader));
93+
let stream_ptr = Box::into_raw(Box::new(stream));
94+
95+
// SAFETY: stream_ptr is a valid allocation we own; the capsule takes
96+
// ownership, and c_stream_capsule_destructor reclaims it.
97+
let capsule_ptr = unsafe {
98+
pyo3::ffi::PyCapsule_New(
99+
stream_ptr.cast(),
100+
c"arrow_array_stream".as_ptr(),
101+
Some(c_stream_capsule_destructor),
102+
)
103+
};
104+
if capsule_ptr.is_null() {
105+
// Capsule creation failed; reclaim the allocation and propagate.
106+
unsafe { drop(Box::from_raw(stream_ptr)) };
107+
return Err(PyErr::fetch(py));
108+
}
109+
// SAFETY: capsule_ptr is a new reference to a valid PyCapsule object.
110+
let capsule = unsafe { Py::<PyAny>::from_owned_ptr(py, capsule_ptr) };
111+
112+
let shim = Py::new(py, ArrowStreamShim { capsule })?;
113+
let pa = py.import("pyarrow")?;
114+
let table = pa
115+
.getattr("RecordBatchReader")?
116+
.call_method1("from_stream", (shim,))?
117+
.call_method0("read_all")?;
118+
Ok(table.into())
119+
}
120+
121+
/// Capsule destructor honoring the Arrow C Stream Interface contract: if the
122+
/// stream was never consumed (its release callback is still set), release it;
123+
/// then reclaim the allocation. When pyarrow consumes the stream it moves the
124+
/// contents out and nulls the release callback, so this destructor only frees
125+
/// the (now-empty) allocation in that case.
126+
unsafe extern "C" fn c_stream_capsule_destructor(capsule: *mut pyo3::ffi::PyObject) {
127+
let ptr = unsafe { pyo3::ffi::PyCapsule_GetPointer(capsule, c"arrow_array_stream".as_ptr()) }
128+
.cast::<FFI_ArrowArrayStream>();
129+
if ptr.is_null() {
130+
return;
131+
}
132+
let mut stream = unsafe { Box::from_raw(ptr) };
133+
if let Some(release) = stream.release {
134+
// SAFETY: per the C Data Interface contract, release is called at
135+
// most once and marks the stream as released.
136+
unsafe { release(&mut *stream) };
137+
}
138+
}
139+
140+
/// Python-side holder for an Arrow C Stream capsule, exposing the
141+
/// `__arrow_c_stream__` protocol so pyarrow can consume the stream.
142+
#[pyclass]
143+
struct ArrowStreamShim {
144+
capsule: Py<PyAny>,
145+
}
146+
147+
#[pymethods]
148+
impl ArrowStreamShim {
149+
#[pyo3(signature = (requested_schema=None))]
150+
fn __arrow_c_stream__(
151+
&self,
152+
py: Python<'_>,
153+
requested_schema: Option<&Bound<'_, PyAny>>,
154+
) -> PyResult<Py<PyAny>> {
155+
if requested_schema.is_some_and(|s| !s.is_none()) {
156+
return Err(PyErr::new::<pyo3::exceptions::PyNotImplementedError, _>(
157+
"requested_schema is not supported",
158+
));
159+
}
160+
Ok(self.capsule.clone_ref(py))
161+
}
162+
}
163+
164+
/// Convert a Python pyarrow Table to a ggsql DataFrame via Arrow IPC
165+
/// serialization (legacy fallback; see GGSQL_ARROW_IPC)
166+
fn py_to_df_ipc(py: Python<'_>, obj: &Bound<'_, PyAny>) -> PyResult<DataFrame> {
63167
let pa = py.import("pyarrow")?;
64168
let table = if obj.is_instance(&pa.getattr("Table")?)? {
65169
obj.clone()
@@ -112,6 +216,80 @@ fn py_to_df(py: Python<'_>, obj: &Bound<'_, PyAny>) -> PyResult<DataFrame> {
112216
Ok(DataFrame::from_record_batch(combined))
113217
}
114218

219+
/// Convert a Python pyarrow Table to a ggsql DataFrame.
220+
///
221+
/// Uses the zero-copy Arrow C Data Interface by default; set GGSQL_ARROW_IPC=1
222+
/// to fall back to Arrow IPC serialization.
223+
fn py_to_df(py: Python<'_>, obj: &Bound<'_, PyAny>) -> PyResult<DataFrame> {
224+
if use_ipc_fallback() {
225+
py_to_df_ipc(py, obj)
226+
} else {
227+
py_to_df_stream(py, obj)
228+
}
229+
}
230+
231+
/// Convert a Python pyarrow Table to a ggsql DataFrame via the Arrow C Data
232+
/// Interface (zero-copy).
233+
fn py_to_df_stream(py: Python<'_>, obj: &Bound<'_, PyAny>) -> PyResult<DataFrame> {
234+
let pa = py.import("pyarrow")?;
235+
let table = if obj.is_instance(&pa.getattr("Table")?)? {
236+
obj.clone()
237+
} else {
238+
pa.call_method1("table", (obj,)).map_err(|e| {
239+
let type_name = obj
240+
.get_type()
241+
.name()
242+
.map(|n| n.to_string())
243+
.unwrap_or_else(|_| "<unknown>".to_string());
244+
PyErr::new::<pyo3::exceptions::PyTypeError, _>(format!(
245+
"Expected a pyarrow.Table or compatible type, got {}: {}",
246+
type_name, e
247+
))
248+
})?
249+
};
250+
251+
let capsule = table.call_method0("__arrow_c_stream__")?;
252+
let capsule = capsule.downcast::<PyCapsule>().map_err(|_| {
253+
PyErr::new::<pyo3::exceptions::PyValueError, _>(
254+
"__arrow_c_stream__ did not return a PyCapsule".to_string(),
255+
)
256+
})?;
257+
let stream_ptr = capsule.pointer().cast::<FFI_ArrowArrayStream>();
258+
if stream_ptr.is_null() {
259+
return Err(PyErr::new::<pyo3::exceptions::PyValueError, _>(
260+
"Invalid Arrow C stream capsule".to_string(),
261+
));
262+
}
263+
264+
// SAFETY: stream_ptr comes from a valid "arrow_array_stream" capsule
265+
// produced by pyarrow. from_raw moves the stream out and sets the
266+
// capsule's copy to a released state, so the capsule destructor will not
267+
// double-release it (it still frees the now-empty allocation).
268+
let reader = unsafe { ArrowArrayStreamReader::from_raw(stream_ptr) }.map_err(|e| {
269+
PyErr::new::<pyo3::exceptions::PyValueError, _>(format!(
270+
"Failed to import Arrow C stream: {}",
271+
e
272+
))
273+
})?;
274+
275+
let batches: Vec<_> = reader.collect::<Result<Vec<_>, _>>().map_err(|e| {
276+
PyErr::new::<pyo3::exceptions::PyValueError, _>(format!(
277+
"Failed to read RecordBatch from Arrow C stream: {}",
278+
e
279+
))
280+
})?;
281+
282+
if batches.is_empty() {
283+
return Ok(DataFrame::empty());
284+
}
285+
286+
let combined = arrow::compute::concat_batches(&batches[0].schema(), &batches).map_err(|e| {
287+
PyErr::new::<pyo3::exceptions::PyValueError, _>(format!("Failed to concat batches: {}", e))
288+
})?;
289+
290+
Ok(DataFrame::from_record_batch(combined))
291+
}
292+
115293
/// Convert a Python object to a ggsql DataFrame - for use inside Python::attach
116294
/// This variant is used by PyReaderBridge where we already hold the GIL.
117295
fn py_to_df_inner(obj: &Bound<'_, PyAny>) -> PyResult<DataFrame> {
@@ -1213,6 +1391,7 @@ fn _ggsql(m: &Bound<'_, PyModule>) -> PyResult<()> {
12131391
m.add_class::<PyVegaLiteWriter>()?;
12141392
m.add_class::<PyValidated>()?;
12151393
m.add_class::<PySpec>()?;
1394+
m.add_class::<ArrowStreamShim>()?;
12161395

12171396
// Functions
12181397
m.add_function(wrap_pyfunction!(validate, m)?)?;

0 commit comments

Comments
 (0)