Skip to content

[SPARK-60586][CONNECT] Export Arrow tables without IPC reserialization - #103

Closed
dbtsai wants to merge 9 commits into
apache:masterfrom
dbtsai:codex/shared-python-arrow
Closed

dbtsai wants to merge 9 commits into
apache:masterfrom
dbtsai:codex/shared-python-arrow

Conversation

@dbtsai

@dbtsai dbtsai commented Oct 27, 2026 •

Copy link
Copy Markdown
Member

What changes were proposed in this pull request?

Replace the extra Rust Arrow IPC file -> Python bytes -> PyArrow decoding step in native DataFrame.toArrow() with the Arrow C Data Interface. An unpublished spark-python-arrow workspace crate exposes one table-export entry point, used by pyspark-rs.

This PR now contains only the Arrow export optimization. PyO3 stays on 0.28.3, matching upstream; the dependency upgrade is deferred in #107. Row/scalar conversion code is unchanged from upstream. Python-to-Rust Arrow imports, standalone array/type exports, and worker-side UDF conversion are not included.

Why are the changes needed?

Avoid reserializing already decoded Arrow batches and allocating another full Python bytes copy before building a PyArrow table. This reduces handoff time and temporary memory without changing the Spark Connect wire protocol. Tracked by SPARK-60586.

Does this PR introduce any user-facing change?

toArrow() shares compatible Arrow value and offset buffers with PyArrow while preserving batch boundaries, metadata, and buffer lifetimes. toPandas() uses this path but may still copy during pandas materialization.

Zero-copy applies to the decoded Rust Arrow -> PyArrow handoff, not network IPC decoding, schema descriptors, Python rows, or pandas conversion. Sliced validity bitmaps may need realignment. NUL-containing field names/timezones retain the IPC fallback without repeating the RPC.

Inconsistent batch schemas, including metadata and dictionary ordering, now raise ValueError instead of being written under the first schema. Empty batch vectors retain the old empty-schema behavior. See crates/spark-python-arrow/README.md for the export contract.

How was this patch tested?

Local validation on macOS arm64, Rust 1.99.1, with an optimized release/abi3-py39 wheel:

  • 11 converter tests passed, including buffer-address identity, lifetime, slices, nested layouts, metadata, empty shapes, schema mismatches, and IPC fallback. Converter strict Clippy and workspace formatting passed.
  • 291 Python tests passed on each environment: Python 3.9/PyArrow 10 and Python 3.12/PyArrow 26 (273 existing offline tests + 18 handoff cases).
  • 388 core Rust unit tests passed; all integration tests compiled with and without wasm-udf.
  • Official collection parity through the Rust transport: 15 passed, 1 skipped. Three additional native toArrow() smoke cases passed against a local Spark Connect server, covering nested/null/binary/date/timestamp values, numeric ranges, and empty results.
  • Public API-name comparison across 12 classes is unchanged. This is not a claim of full behavioral/signature parity.

Before/after benchmark

Used dbtsai/pyspark-connect-benchmark, comparing upstream fbd6af5763bae5cd572ba4438026e675165ba70a with this Arrow-only change. Both wheels include upstream's recent plan/row sharing, use PyO3 0.28.3 and Arrow 58.3.0, and were built offline with identical release optimization (opt-level=3) and ABI settings.

Python 3.12.14, PyArrow 26.0.0, pandas 2.3.3, NumPy 2.5.4 and psutil 7.2.2 match. These public client operations receive canned localhost gRPC responses, not real Spark execution. Peak RSS is sampled client-process resident memory, not allocation counts; it includes allocator/warmup retention and can miss brief spikes.

Initial run: 12 cases, six independent fresh-process samples per client/case. Median times and sampled peak RSS:

Workload Time before / after Change Peak RSS before / after
core-return-long-w50-b10000-toArrow 29.72 / 14.56 ms -51.0% 269.16 / 147.29 MiB
return-buffer-134217728-toArrow 57.73 / 34.23 ms -40.7% 631.83 / 240.70 MiB
core-return-string-w4-b1000-toArrow 6.86 / 5.40 ms -21.2% 135.41 / 112.98 MiB
core-return-string-w4-b10000-toArrow 4.53 / 3.26 ms -28.0% 139.00 / 111.95 MiB
return-nullable50-toArrow 1.04 / 1.03 ms -0.9% 104.17 / 103.07 MiB
return-nested3-toArrow 1.74 / 1.17 ms -32.6% 106.62 / 103.70 MiB
allocation-first-4096x512-toArrow 13.39 / 9.38 ms -30.0% 163.91 / 127.52 MiB
core-return-long-w50-b10000-toPandas 32.10 / 16.07 ms -49.9% 309.98 / 187.05 MiB
return-nested3-toPandas 13.86 / 13.96 ms +0.7% 129.17 / 126.80 MiB
core-return-long-w4-b1000-collect 12.32 / 12.10 ms -1.8% 136.97 / 137.70 MiB
core-return-string-w4-b1000-collect 22.41 / 22.90 ms +2.2% 145.53 / 145.17 MiB
input-pandas-explicit-10000x3 19.35 / 19.39 ms +0.2% 113.99 / 114.20 MiB

A separate confirmation used 12 samples per client/case with baseline/candidate harness slots swapped:

Confirmation workload Time before / after Change Peak RSS before / after
38 MiB numeric toArrow() 30.99 / 15.05 ms -51.4% 270.27 / 147.27 MiB
128 MiB numeric toArrow() 64.40 / 39.13 ms -39.2% 637.06 / 241.39 MiB
38 MiB numeric toPandas() 31.74 / 16.21 ms -48.9% 309.84 / 187.22 MiB
Numeric collect() control 12.42 / 12.36 ms -0.5% 137.70 / 137.41 MiB
String collect() control 22.75 / 22.99 ms +1.0% 145.69 / 145.72 MiB

The two collection controls have overlapping timing ranges in both runs; the earlier PyO3-upgrade slowdown is not reproduced here. The large Arrow cases retain substantial speed and memory gains. Small nullable results and nested pandas conversion show little timing change, so this is not a claim that every operation gets faster.

All 396 samples across the three clients passed validation (216 initial + 180 confirmation). Rust request counters matched, and source/wheel identities were unchanged during both runs. The runs are reported separately, not pooled. Buffer-address/lifetime tests establish zero-copy sharing; benchmark timings alone do not.

Remaining draft checks

  • Full official parity suite and minimum-compiler validation (the converter declares Rust 1.85 because Arrow 58.3 requires it; local builds used 1.99.1).
  • Review CI. Workspace Clippy still reports errors in unchanged core/binding code; strict converter Clippy passes. The pre-existing SparkSession.Hook API gap remains outside this change.

Was this patch authored or co-authored using generative AI tooling?

Generated-by: OpenAI Codex (application version unavailable).

Share Arrow C Data handoff, scalar conversion, and explicit UDF conversion policy between Python-facing Spark consumers. Adopt the bridge in toArrow and shared scalar helpers in client input and row conversion.

Generated-by: OpenAI Codex

Co-authored-by: Isaac <no-reply@databricks.com>
@dbtsai dbtsai changed the title [WIP][CONNECT] Extract a shared Python and Arrow converter [SPARK-60586][CONNECT] Extract a shared Python and Arrow converter Oct 27, 2026
dbtsai and others added 6 commits October 27, 2026 12:42
Add UDF converter, fallback, mutation and error-ordering regressions, retain converted capacity fallbacks, and preserve sorted-map and dictionary-order metadata. Document nullable callback and buffer ownership contracts and add isolated PyO3 compatibility CI.

Generated-by: OpenAI Codex

Co-authored-by: Isaac <no-reply@databricks.com>
Build the OSS Python extension with PyO3 0.30.1 and declare its Rust 1.85 requirement. Retain the shared converter's PyO3 0.28 and 0.29 compatibility and add 0.30.1 to its isolated CI matrix.

Co-authored-by: Isaac <no-reply@databricks.com>
Replace the environment-gated early return with an explicit ignored test. Run the official converter comparison with --include-ignored in each PyO3 compatibility CI leg and document the local commands.

Co-authored-by: Isaac <no-reply@databricks.com>
Defer the worker-only UDF policy and helpers until a concrete consumer can accompany them. Keep the adopted bridge and scalar paths, mark the implementation crate unpublished, use the repository license, and document schema-mismatch errors and the current compatibility contract.

Co-authored-by: Isaac <no-reply@databricks.com>
Inline client string and date materialization, remove stale worker and decoder wording, and exercise the remaining Scalar string API.

Co-authored-by: Isaac <no-reply@databricks.com>
Remove unused inbound and standalone conversion APIs, keep scalar conversion in the client binding, declare the Arrow/PyO3 Rust minimum, and focus tests and documentation on the production export path.

Co-authored-by: Isaac <no-reply@databricks.com>
@dbtsai dbtsai changed the title [SPARK-60586][CONNECT] Extract a shared Python and Arrow converter [SPARK-60586][CONNECT] Export Arrow tables without IPC and upgrade PyO3 to 0.30.1 Oct 27, 2026
Defer the PyO3 upgrade and restore upstream row/scalar conversion. Keep the Arrow export optimization and its regression coverage, testing the converter against the workspace's locked PyO3 0.28.3.

Generated-by: OpenAI Codex
@dbtsai dbtsai changed the title [SPARK-60586][CONNECT] Export Arrow tables without IPC and upgrade PyO3 to 0.30.1 [SPARK-60586][CONNECT] Export Arrow tables without IPC reserialization Oct 27, 2026
@tdcmeehan

Copy link
Copy Markdown

Thanks for this. Avoiding the IPC round-trip is a clear win, and the benchmarks make the case well.

Before this lands, I'd like to question how much of the bridge needs to be custom. arrow-rs 58.3 already ships this handoff: arrow::pyarrow::Table implements IntoPyArrow via pa.Table.from_batches, on the same PyO3 0.28 line this workspace uses. As far as I can tell, the main reason to rebuild the descriptors here is the sorted-map flag that TryFrom<&Field> overwrites, which apache/arrow-rs#11630 fixes.

But I don't think Spark can produce that case:

  • Spark always declares maps with keysSorted = false (ArrowUtils.toArrowField: new ArrowType.Map(false)), so there's no sorted flag to lose.
  • Connect results go through ArrowConverters.toBatchWithSchemaIterator → ArrowUtils.toArrowSchema, which never produces dictionary-encoded fields. So the dictionary-order checks in schemas_equal cover input the server doesn't send either.

If that's right, Table::try_new(batches, schema).map_err(arrow_error)?.into_pyarrow(py) plus the NUL-name IPC fallback should give the same zero-copy result with much less code to maintain. It would also avoid a problem on the next upgrade: in arrow 60, FFI_ArrowSchema.flags is private, so the schema.flags = source.flags workaround won't compile.

A couple of questions:

  1. Was there a reason to avoid arrow-rs's export that I'm missing? The _forbid_c_stream_import fixture rules out the single-batch C stream that RecordBatch::to_pyarrow uses (a workaround for [Python] RecordBatch.from_arrays() segfaults if passed storage array arrow#37669). Is there a correctness or performance problem with that path? If so, it'd be worth a sentence in the PR description or the README.
  2. Is there a Spark-produced schema (any server version) that hits the sorted-map or ordered-dictionary paths? If so, a test using that schema would document why the extra handling is needed. If not, I'd suggest dropping that handling and scoping the bridge to what Spark actually sends.

Comment on lines +88 to +89
CARGO_NET_OFFLINE=true CARGO_REGISTRIES_CRATES_IO_PROTOCOL=git \
cargo test -p spark-python-arrow --locked --features pyo3/abi3-py39

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

This command differs from what CI runs (cargo test -p spark-python-arrow --locked --features pyo3/abi3-py39 in python-arrow.yml). CARGO_NET_OFFLINE=true makes it fail unless every dependency is already in the local Cargo cache, and CARGO_REGISTRIES_CRATES_IO_PROTOCOL=git switches away from Cargo's default sparse index. Could the README show the CI command, with the offline variables as an optional note for offline environments?

Comment on lines +49 to +50
- name: Install PyArrow
run: python -m pip install "pyarrow==26.0.0"

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

pyproject.toml declares pyarrow>=10.0.0, and toArrow() now goes through PyArrow's private Schema._import_from_c / RecordBatch._import_from_c instead of the public IPC reader. CI pins only 26.0.0 here, and the other workflows install the latest PyArrow, so the PyArrow 10 run in the PR description isn't repeated in CI. Could this job add a leg at the declared floor? If that isn't practical, the README could state which PyArrow versions are tested.

Comment on lines +113 to +115
// Arrow 58's Field conversion overwrites a map's type flags with its field flags.
// Rebuild the descriptor tree using public constructors, retaining both. Only
// descriptors are rebuilt: array buffers are not involved in schema conversion.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Could this link the upstream issue (apache/arrow-rs#11603, fixed on main by apache/arrow-rs#11630)? That makes it clear when export_c_data_type/rebuild and the 0b100 flag below can be removed after an Arrow upgrade.

Comment on lines +273 to +277
for (field, expected) in cases {
let batch = RecordBatch::new_empty(Arc::new(Schema::new(vec![field])));
let table = record_batches_to_pyarrow(py, vec![batch])?;
assert_schema_equal(&table.bind(py).getattr("schema")?, &evaluate(py, expected)?)?;
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Both NUL-fallback tests use a single batch: this one (empty) and test_nul_schema_strings_fall_back_without_executing_again. Two more cases would cover the rest of the fallback's contract: (1) two non-empty batches, asserting num_chunks == 2 and the values; (2) a NUL field name with mismatched batch schemas, expecting ValueError. Case (2) pins the schema check in bridge.rs (L60-66) ahead of the fallback branch (L68).

@viirya viirya left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Thanks for splitting out the PyO3 upgrade and adding benchmarks. The memory numbers for large toArrow()/toPandas() results make a good case. I checked the FFI ownership and it looks right: the schema is imported before any array, rejected imports are released through Drop, and zero-column batches keep their row count. +1 to @tdcmeehan's question about arrow::pyarrow::Table. The answer decides how much of this crate needs to stay, so I'll avoid repeating those points.

One gap in the testing evidence: the official collection parity run goes through rust_transport_plugin.py, so official pyspark decodes the Arrow data and PyDataFrame.toArrow never runs. The pandas-parity gate works the same way. The three native toArrow() smoke cases you ran against a local server are the only real-server coverage of this path, and they aren't in the PR. Could you commit them as drop-in e2e tests so CI runs them on every change?

));
}
let pa = py.import("pyarrow")?;
if schema.fields().iter().any(|field| field_needs_ipc(field)) {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Instead of walking the type tree up front, could this call export_schema first and fall back to IPC if it returns an error? The schema is exported before any array, so nothing has been consumed when it fails. That would remove field_needs_ipc/data_type_needs_ipc (L202–225) and also cover any other schema the C exporter rejects. If you move to arrow::pyarrow::Table, the same approach works, but keep a clone of the batch Vec for the fallback (cheap, since the batches are Arc-backed), because into_pyarrow consumes the table.

name = "spark-python-arrow"
version = "0.1.0"
edition = "2021"
rust-version = "1.85"

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Now that the crate is no longer built outside the workspace, edition, license and arrow could use .workspace = true like every other crate. On rust-version: apache-spark-connect already depends on arrow 58.3, which declares Rust 1.85, so the workspace's 1.82 is already wrong for the core crate too, not just this one (README L76). A separate change that raises the workspace value to 1.85 would let this crate inherit it and make that README paragraph unnecessary.

python-version: "3.11"
- name: Install Rust
run: |
rustup toolchain install stable --profile minimal --component clippy

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

This installs latest stable and then runs clippy with -D warnings (L59), so the job will start failing whenever a new stable release adds lints, even if this crate hasn't changed. Could it pin a toolchain, or match the workspace lint job's setup?



@contextmanager
def _dataframe(batches, *, payloads=None, calls=None):

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

This file is 521 lines. It hand-encodes the protobuf wire format with hard-coded field numbers, mirrors ArrowArray in ctypes, hooks the release callbacks, and has the autouse _forbid_c_stream_import fixture @tdcmeehan asked about. That's a lot to maintain, and much of it depends on how the export is implemented. Buffer identity and lifetime are already covered by conversion.rs. Could the Python side be cut down to the public toArrow()/toPandas() behavior plus one importer-failure case, with the real-server e2e covering the rest?

@viirya

viirya commented Oct 28, 2026

Copy link
Copy Markdown
Member

Following up on the question about arrow::pyarrow::Table: I think switching would shrink this PR to roughly the toArrow change plus the NUL fallback.

The core of toArrow would become:

let batches = py.detach(|| self.dataframe.collect_record_batches()).to_pyerr()?;
let schema = batches.first().map(|b| b.schema()).unwrap_or_else(|| Arc::new(Schema::empty()));
match Table::try_new(batches.clone(), schema.clone()) { // Arc clones, kept for the fallback
    Ok(table) => match table.into_pyarrow(py) { ... }    // NUL name/timezone -> IPC fallback
    Err(e) => Err(PyValueError::new_err(e.to_string())),
}

That is roughly 20 lines in pyspark-rs/src/dataframe.rs, with arrow's pyarrow feature enabled. At that size I don't think a separate crate is worth it. Without one, the following could go:

  • crates/spark-python-arrow/ entirely: bridge.rs, lib.rs, Cargo.toml, README.md, and both test files.
  • .github/workflows/python-arrow.yml. It exists only because the crate's embedded-Python tests can't be built together with pyspark-rs (extension-module feature unification).
  • The workspace member entry, the pyspark-rs path dependency, and most of the architecture doc changes.

Most of the tests would go with the crate. In bridge_regressions.rs, two tests would fail against the upstream export rather than just becoming redundant:

  • table_export_preserves_sorted_map_flags_in_nested_fields expects keys_sorted=True. arrow 58's TryFrom<&Field> still overwrites that flag (fixed by fix(arrow-schema): keep map keys-sorted flag in FFI field export arrow-rs#11630).
  • table_export_rejects_inconsistent_nested_dictionary_order expects a ValueError. Table::try_new compares schemas with Schema ==, and arrow-rs's Field equality ignores dict_is_ordered.

Spark never produces either input: maps are always written with keysSorted = false, and Connect results contain no dictionary-encoded fields. So both tests can simply be deleted. The other two test arrow-rs's own FFI behavior: ordered-dictionary flags, and sliced validity bitmaps, which arrays decoded from IPC don't have anyway. Those belong upstream.

What I think is worth keeping is the NUL fallback tests and one buffer-address assertion, so a future change can't quietly reintroduce the IPC copy. Both fit in python/tests/test_arrow_handoff.py.

@dbtsai

dbtsai commented Oct 28, 2026

Copy link
Copy Markdown
Member Author

Closing this alternative in favor of #108. We favor its use of arrow-rs's upstream whole-result C Stream reader, which avoids both custom stream callbacks and per-batch reader setup. Further review and validation will continue on #108, where the remaining memory and validation caveats are documented. Thanks for the feedback on these alternatives.

@dbtsai dbtsai closed this Oct 28, 2026
dbtsai added a commit to dbtsai/spark-connect-rust that referenced this pull request Oct 29, 2026
Provide an alternative to the per-batch C Data handoff in PR apache#103 while retaining PyO3 0.28.3. Add ownership, buffer-sharing, compatibility, and public client handoff tests.

Generated-by: OpenAI Codex
dbtsai added a commit that referenced this pull request Oct 29, 2026
… Interface

### What changes were proposed in this pull request?

Under [SPARK-60586](https://issues.apache.org/jira/browse/SPARK-60586), replace the extra Rust Arrow → IPC bytes → PyArrow round trip in native `DataFrame.toArrow()` with arrow-rs's built-in export of one `Box<dyn RecordBatchReader + Send>`:

```rust
let reader: Box<dyn RecordBatchReader + Send> =
    Box::new(RecordBatchIterator::new(batches.into_iter().map(Ok), schema));
Ok(reader.into_pyarrow(py)?.call_method0("read_all")?.unbind())
```

The helper is `crates/pyspark-rs/src/pyarrow_export.rs`. Arrow owns the FFI callbacks, ownership transfer, and release handling; the client adds no custom unsafe code. The separate converter crate and custom callback adapter are gone.

Before exporting, validate batch schema consistency and retain an IPC fallback for field names or timezones containing NUL. The standalone PyArrow schema preflight was removed: its claimed older-version abort rationale was unproven. Arrow 58.3.0 itself uses `CString::new(...).unwrap()` when exporting [formats](https://github.com/apache/arrow-rs/blob/58.3.0/arrow-schema/src/ffi.rs#L148) and [names](https://github.com/apache/arrow-rs/blob/58.3.0/arrow-schema/src/ffi.rs#L174), so the explicit NUL check remains necessary before export.

PyO3 remains **0.28.3**, Arrow **58.3.0**; enabling Arrow's `pyarrow` feature adds `arrow-pyarrow 58.3.0`. The contract covers ordinary Spark results: unsorted maps and non-dictionary-encoded fields. Row/scalar conversion, UDF workers, and Python-to-Rust imports are unchanged. #103 and #117 are alternative implementations, not dependencies.

### Why are the changes needed?

Already-decoded Arrow batches previously had to be serialized into another IPC file, copied into Python bytes, and decoded again. One upstream reader now consumes the entire result without a Python import call per batch. All decoded batches are still collected into a Rust vector first; this does not provide network streaming or bounded-memory collection.

### Does this PR introduce _any_ user-facing change?

`toArrow()` shares compatible decoded value/offset buffers with PyArrow. `toPandas()` uses the same handoff, but pandas conversion may copy. Network IPC decoding, descriptors, Python rows, sliced validity-bitmap realignment, and the compatibility fallback are outside the buffer-sharing claim.

**Intentional behavior change:** inconsistent batch schemas now raise `ValueError`, including differences in nullability or field/schema metadata. Previously batches were written under the first batch's schema. Validation happens before the IPC fallback. Spark normally produces batches from one result schema.

Values, metadata, batch boundaries, and zero-column row counts are covered by public-behavior tests. An empty batch vector retains master's empty-schema behavior; retaining the typed schema when no batches arrive is a separate follow-up. NUL-containing field names/timezones use the already-collected batches for IPC without another RPC.

### How was this patch tested?

Current tested source: **`e60aa53`**, based on upstream master `d884e34`. Rebuilt locally on macOS arm64 with Rust 1.99.1 and optimized release settings:

- **299 passed, 1 skipped** on Python 3.12 / PyArrow 26.0.0.
- **299 passed, 1 skipped** on Python 3.9 / PyArrow 10.0.0, after removing the schema preflight. Each run includes **26 handoff/fixture cases** plus the offline suite.
- **24 fixture-dependent handoff cases skip locally with an actionable message when `protoc` is unavailable; CI fails instead of skipping. Two additional tests cover both missing-protoc outcomes.** With `protoc`, the mock uses descriptors compiled from the repository's protocol instead of hand-encoded field numbers.
- Tests cover values, nested types, metadata, chunks, empty/zero-column results, `toPandas()`, NUL fallback, schema mismatch, and buffers remaining usable after their session/table is dropped. Post-deletion value checks establish retained-buffer lifetime; redundant address-equality assertions were removed. **These checks do not independently prove Rust-to-Python buffer identity.** The private FFI mirrors, callback hooks, schema monkeypatch, and subprocess-isolated boundary spies were removed.
- The normal-handoff regression guard makes `pyarrow.ipc.open_file` fail; it passes on this head and fails against master `d884e34`. NUL cases explicitly verify the IPC fallback, including list-of-struct and map-value-struct names. Schema-mismatch cases now include nullability.
- **392 core Rust unit tests passed**, all integrations compiled with and without `wasm-udf`, **3 native live-server smoke tests passed**, and official collection parity passed (**15 passed, 1 skipped**) on the review-fix source.
- Formatting, Python lint, and whitespace checks pass.

The preceding source `d89c01b` also passed **392 core Rust unit tests**, compiled all core integration tests with and without `wasm-udf`, passed **3 native `toArrow()` live-server smoke tests**, and passed official collection parity through the Rust transport (**15 passed, 1 skipped**). Collection parity required `spark.sql.timeType.enabled=true` on the local Spark 4.2 server. The current review follow-up strengthens regression coverage and documents a matched master RSS control; it makes no production behavior changes.

**CI scope:** the coverage workflow invokes `test_arrow_handoff.py` and installs `protobuf-compiler`. Follow-up `e0eb002` adds a native smoke step in the Connect parity job of `build_python_connect.yml`, using the already-built extension and running `python -m pytest tests/test_arrow_e2e.py -q` with `SPARK_REMOTE` and the local package on `PYTHONPATH`. It runs after the official suite and before the server restarts in local-cluster mode, so the official suite still begins with a pristine server. Workflow YAML parsing and the exact pytest command passed locally (**3 passed**). CI results for this follow-up are separate from those local checks. PyArrow 10 validation remains local; there is no pinned PyArrow 10 CI job.

#### Historical benchmark evidence — not measured on the current head

Using [pyspark-connect-benchmark at `1bbdec9`](https://github.com/dbtsai/pyspark-connect-benchmark/tree/1bbdec9f3665fcd2ec2ee5129bbcdb95ee4b00c4), the earlier upstream-reader candidate `a9ee289` was compared with master `d884e34`. That candidate still had the standalone schema preflight and the older test suite. The PR has since been rebased and updated; these timings are **historical results, not current-head measurements**.

The matched optimized builds used Rust 1.99.1, `opt-level=3`, `abi3-py39`, PyO3 0.28.3, Arrow 58.3.0, Python 3.12.14 and PyArrow 26.0.0 on an Apple M3 Max. **240/240 samples passed** across 10 workloads, 6 fresh processes per client, and 2 counterbalanced rounds. Validation checked shape and first/last values per column, not every cell.

Representative historical medians, **master → earlier candidate**:

| Workload | Round | Wall time (ms) | Sampled peak RSS (MiB) |
|---|---|---:|---:|
| 38 MiB numeric `toArrow()` | Forward | 24.843 → 14.255 | 268.94 → 146.92 |
| 38 MiB numeric `toArrow()` | Reversed | 27.990 → 14.005 | 268.90 → 146.92 |
| 128 MiB numeric `toArrow()` | Forward | 63.207 → 30.491 | 628.53 → 235.24 |
| 128 MiB numeric `toArrow()` | Reversed | 63.300 → 31.891 | 629.55 → 236.22 |
| 38 MiB numeric `toPandas()` | Forward | 30.023 → 15.494 | 308.38 → 186.51 |
| 38 MiB numeric `toPandas()` | Reversed | 29.995 → 15.163 | 308.52 → 186.50 |

These are shared-desktop measurements of client receive/conversion against canned localhost RPC responses, not Spark execution. RSS sampling can miss short peaks; timings and RSS do not establish buffer identity.

**RSS control added during review.** The identical 28-call, 128 MiB probe was rerun in fresh processes against rebuilt master `d884e34` and this C Stream revision, using the same environment. After four warmup calls, master rose from **1129.23 to 3976.06 MiB**, while C Stream rose from **418.81 to 569.47 MiB**. All results passed validation. Growth is therefore not unique to this PR; the control grew more in this single run. Both mock server and client still share one process, so this does not identify the retention source or establish leak freedom or a general memory advantage. The earlier C Stream probe rose from 416.27 to 481.66 MiB, and run-to-run variation remains unexplained. The methodology and reproducer are included below.

<details>
<summary>RSS control methodology and reproducer</summary>

This is a local diagnostic for PR #108, not a performance benchmark or proof of
leak freedom. Both the mock gRPC server and the native client run in one Python
process, so the measurement includes their allocations and allocator retention.

On 2026-10-29, the same 28-call probe was run in fresh processes on master
`d884e34` (IPC handoff) and the review revision of #108 based on `fe79d54`
(C Stream handoff; production changes since that head are comments only).
Both extensions were rebuilt with the same release Cargo configuration and Rust
1.99.1, then loaded using the same Python 3.12 / PyArrow 26 environment on macOS
arm64. Master ran first. These are single runs on a shared desktop.

Each call receives 128 one-MiB numeric batches from the same mock service,
validates the 16,777,216-row table's endpoints, deletes the table, forces Python
GC, and samples process RSS. The first four calls are warmup. All calls passed
validation.

| Handoff | First measured RSS (MiB) | Last measured RSS (MiB) | Measured minimum–maximum (MiB) |
| --- | ---: | ---: | ---: |
| Master IPC | 1129.23 | 3976.06 | 1129.23–3976.06 |
| PR C Stream | 418.81 | 569.47 | 418.81–569.47 |

Growth is not unique to the C Stream change; the master control grew much more
in this run. This does not identify the source of retention, isolate client
allocations, or establish a general memory advantage. The earlier C Stream run
reported 416.27 → 481.66 MiB; variation between runs remains unexplained. A claim
of flat post-GC RSS is not supported.

To reproduce, run the following from the PR checkout with its tests available,
setting `PYTHONPATH` to `<PR checkout>/python`, the directory containing `pyspark`.
For the master control, build master and set `PYTHONPATH` to `<master checkout>/python`;
keep this test harness and all other environment settings the same. `PROTOC`
and `PROTOC_INCLUDE` must identify an installed protocol compiler and includes.
The baseline IPC implementation does not include the new test file, so the
working directory must remain the PR checkout for both runs.

```python
import gc
import json
import runpy
import tempfile
from pathlib import Path

import numpy as np
import psutil
import pyarrow as pa

namespace = runpy.run_path("python/tests/test_arrow_handoff.py")

class TempFactory:
    def mktemp(self, name):
        return Path(tempfile.mkdtemp(prefix=name))

protocol = namespace["protocol"].__wrapped__(TempFactory())
batch = pa.record_batch(
    [pa.array(np.arange(131072, dtype=np.int64))], names=["value"]
)
process = psutil.Process()
samples = []
with namespace["_dataframe"]([batch] * 128, protocol=protocol) as (frame, session):
    for iteration in range(28):
        table = frame.toArrow()
        assert table.num_rows == 16777216
        assert table.column(0)[0].as_py() == 0
        assert table.column(0)[-1].as_py() == 131071
        del table
        gc.collect()
        samples.append(process.memory_info().rss / 1048576)
print(json.dumps({"warmup": 4, "post_gc_rss_mib": samples}))
```

</details>

#### Remaining validation and follow-ups

- [x] Run a matched master control for the repeated-result RSS probe.
- [ ] Isolate the source of retention and investigate the historical high-RSS tail.
- [ ] Rerun comparative benchmarks on the current head.
- [ ] Full official parity suite and minimum-Rust build. Existing workflows use stable Rust; `pyspark-rs` declares 1.85 while the workspace still declares 1.82. Workspace MSRV cleanup is separate.
- [ ] Review CI and outstanding baseline lint/API gaps. Workspace Clippy fails in unchanged core/binding code; the existing `SparkSession.Hook` gap remains. Local checks are not a green-CI claim.

### Was this patch authored or co-authored using generative AI tooling?

Generated-by: OpenAI Codex (application version unavailable).

This pull request and its description were written by Isaac.

Closes #108 from dbtsai/codex/arrow-c-stream.

Authored-by: DB Tsai <dbtsai@dbtsai.com>
Signed-off-by: DB Tsai <dbtsai@dbtsai.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants