Repository navigation
[WIP][CONNECT] Resume reattachable execution after clean EOF - #139
Open
WindsorWZZ wants to merge 5 commits into
Open
WindsorWZZ wants to merge 5 commits into
WindsorWZZ wants to merge 5 commits into
Conversation
Treat a clean EOF before ResultComplete as a stream boundary and resume from the last response ID. Cover EOF, completion, transient failures, and retry exhaustion with in-memory gRPC regressions. Co-authored-by: Isaac <no-reply@databricks.com>
… 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. apache#103 and apache#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 apache#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 apache#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 apache#108 from dbtsai/codex/arrow-c-stream. Authored-by: DB Tsai <dbtsai@dbtsai.com> Signed-off-by: DB Tsai <dbtsai@dbtsai.com>
Treat a clean EOF before ResultComplete as a stream boundary and resume from the last response ID. Cover EOF, completion, transient failures, and retry exhaustion with in-memory gRPC regressions. Co-authored-by: Isaac <no-reply@databricks.com>
Use the explicit stream-end decision helper and cover graceful EOF with retries disabled, completion, and retryable/non-retryable status handling. Co-authored-by: Isaac <no-reply@databricks.com>
Retain the updated stream-end helper and regression coverage while integrating the published branch ancestry. Co-authored-by: Isaac <no-reply@databricks.com>
WindsorWZZ
marked this pull request as ready for review
October 30, 2026 07:12
This branch has not been deployed
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
What changes were proposed in this pull request?
Resume a reattachable ExecutePlan stream with ReattachExecute when the server closes it cleanly before ResultComplete. Reuse the last observed response ID and the existing retry handling.
Draft proposal: the public Apache SPARK issue key is pending.
Why are the changes needed?
Spark can close an individual response stream at its duration or size limit while the execution is still running. Treating that EOF as completion silently truncates results. The protocol requires the client to reattach until it receives ResultComplete.
Does this PR introduce any user-facing change?
Yes. Reattachable executions continue receiving results after a clean stream closure instead of returning incomplete results. Non-retryable errors and retry exhaustion still surface to the caller.
How was this patch tested?
wasm-udf. The three existing core live-server tests remain ignored.cargo fmt --all --checkpasses. Core Clippy (--all-targets --locked) completes successfully with existing warnings.The existing Rust CI workflow runs these tests through
cargo test -p apache-spark-connect -p apache-spark-connect-core --release.Was this patch authored or co-authored using generative AI tooling?
Generated-by: OpenAI Codex (GPT-6)
This pull request and its description were written by Isaac.