This is an automated email from the ASF dual-hosted git repository.
HyukjinKwon pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/spark-connect-rust.git
The following commit(s) were added to refs/heads/master by this push:
new 9d01795 [SPARK-58983][INFRA] Fix CI so all workflows run and pass
9d01795 is described below
commit 9d0179592039d5e4f7a53599c5de17b388c83659
Author: Hyukjin Kwon <[email protected]>
AuthorDate: Tue Aug 25 16:21:10 2026 +0900
[SPARK-58983][INFRA] Fix CI so all workflows run and pass
### What changes were proposed in this pull request?
This makes CI on apache/spark-connect-rust actually **run and go green**.
It started as
"replace non-allowlisted GitHub Actions" (every workflow was failing at
`startup_failure`);
once the workflows could start, several genuine failures surfaced that the
default build
never exercised, and this PR fixes those too.
**1. Use only ASF-allowlisted actions (fixes `startup_failure`).** Replace
third-party
actions across all workflows with GitHub-authored `actions/*` + shell:
- `actions-rust-lang/setup-rust-toolchain`, `dtolnay/rust-toolchain` →
`rustup`
(preinstalled on the runners) + `actions/cachev4` for the cargo/`target`
cache.
- `arduino/setup-protoc` → `protobuf-compiler` via `apt`/`brew`/`choco`
(per-OS).
- `PyO3/maturin-action` → `pip install maturin && maturin build`.
- `pypa/gh-action-pypi-publish` → `twine upload`.
**2. Fix a pre-existing crate-name bug** in `build_python_connect.yml`'s
`rust-checks` job,
which ran `cargo test -p spark-connect -p spark-connect-core` using the old
crate names
(renamed to `apache-spark-connect` / `apache-spark-connect-core`), so it
could never pass.
**3. Fix a `wasm-udf` compile error:** a unit test named
`parse_python_version_output`
shadowed the module function of the same name (imported via `use
super::*`); renamed the
test to `parse_python_version_output_trims_whitespace` so the `wasm-udf`
feature compiles.
**4. Run the WASM packer against the reference pyspark.** The `wasm-udf`
"Packer
end-to-end" step pointed `SPARK_CONNECT_WASM_PACKER_PATH` at `./python`,
which put the
Rust-backed `pyspark` skin on the packer's import path.
`pyspark_wasm_udf.pack` needs
`pyspark.cloudpickle` and `pyspark.sql.types._parse_datatype_json_value`,
and must emit a
reference `DataType` the executors unpickle — none of which the skin
provides. Isolate
`pyspark_wasm_udf` in a `.packer` dir so `import pyspark` resolves to the
reference pyspark
installed earlier in the job.
**5. Unblock the polars build.** The datafusion/polars conversion build
failed on the CI's
stable rustc: the transitive dep `ethnum 1.5.0` does `mem::transmute::<(),
TryFromIntError>(())`,
which no longer compiles now that std's `TryFromIntError` is not zero-sized
(E0512: source
`()` = 0 bits, target `TryFromIntError` = 8 bits). Bump `ethnum` to `1.5.3`
(still within
polars' `^1.5` requirement).
**6. Move the example crates and datasets under `examples/`**
(`examples/wasm-udfs`,
`examples/wasm-udf-inline`, `examples/datasets`) and update the workflow
paths accordingly;
also drop the unused `docker-compose.yml` / `pre-commit.sh` /
`.pre-commit-config.yaml`.
**7. Fix a transport-injection stub hang that timed out the Connect parity
gate.** On
`test_client.py`, `test_server_unreachable` builds a client to an
unresolvable host
(`sc://foo`) with the Python retry policy set to `max_retries=0` and
expects a prompt
`UNAVAILABLE`. Our `RustConnectStub` (used only by the parity harness's
`rust_transport_plugin`) ran RPCs through the Rust client's `with_retry`
using
`RetryPolicy::default()` (15 retries, backoff to 64s) — but the stub is a
drop-in for the
single-shot grpcio stub, over which the reference client layers its *own*
retry handler.
Retrying inside our client double-retried and ignored the client-configured
policy, turning
a fast failure into a ~700s hang (`ours(p=0,f=0) TIMEOUT`). Fix: add
`RetryPolicy::no_retries()` and set it on the injection stub so RPCs are
single-shot and the
reference client owns retrying (real drop-in client keeps
`RetryPolicy::default()`); also
bound `grpc.ready()` and add `connect_timeout`, surfacing an unreachable
channel as
`UNAVAILABLE` instead of waiting on a lazy channel that never connects.
`test_client.py` now
passes 41/41 through our transport, matching the reference.
### Why are the changes needed?
Every workflow failed with `startup_failure` on pushes to `master` and on
pull requests, so
the repository had no working CI. The workflow YAML is valid; the ASF
GitHub org restricts
Actions to `actions/*` plus a vetted allowlist, and these workflows
referenced third-party
actions that aren't on it, so the runs never started. Once that was fixed,
items 2–5 are the
concrete build/test failures that had never run before — each would
otherwise keep CI red.
### Does this PR introduce any user-facing change?
No — CI/infra and repo layout only.
### How was this patch tested?
CI on this PR itself is the end-to-end validation. Locally: the WASM packer
end-to-end test
(`packer_produces_nonempty_command`) passes against the reference pyspark
with
`pyspark_wasm_udf` isolated on the packer path; `actions/*`-only was
verified (a `grep` for
non-`actions/*` `uses:` is empty) and all workflow files parse as valid
YAML.
JIRA: https://issues.apache.org/jira/browse/SPARK-58983
This pull request and its description were written by Isaac.
Closes #52 from HyukjinKwon/fix-ci-actions.
Authored-by: Hyukjin Kwon <[email protected]>
Signed-off-by: Hyukjin Kwon <[email protected]>
---
.github/workflows/build_python_connect.yml | 52 ++++--
.github/workflows/lint.yml | 20 ++-
.github/workflows/release.yml | 52 +++---
.github/workflows/rust.yml | 54 +++++--
.gitignore | 3 +
.isaac/config.json | 3 +
.pre-commit-config.yaml | 42 -----
Cargo.lock | 4 +-
Cargo.toml | 15 +-
README.md | 31 ++--
crates/pyspark-rs/src/transport.rs | 9 +-
crates/spark-connect-core/src/client.rs | 45 ++++--
crates/spark-connect-core/src/retries.rs | 13 ++
crates/spark-connect/src/wasm_udf.rs | 6 +-
docker-compose.yml | 33 ----
examples/Cargo.toml | 2 +-
examples/README.md | 6 +-
.../datasets}/dir1/dir2/file2.parquet | Bin
{datasets => examples/datasets}/dir1/file1.parquet | Bin
{datasets => examples/datasets}/dir1/file3.json | 0
{datasets => examples/datasets}/employees.json | 0
{datasets => examples/datasets}/full_user.avsc | 0
{datasets => examples/datasets}/kv1.txt | 0
{datasets => examples/datasets}/people.csv | 0
{datasets => examples/datasets}/people.json | 0
{datasets => examples/datasets}/people.txt | 0
{datasets => examples/datasets}/user.avsc | 0
{datasets => examples/datasets}/users.avro | Bin
{datasets => examples/datasets}/users.orc | Bin
{datasets => examples/datasets}/users.parquet | Bin
.../wasm-udf-inline}/Cargo.toml | 14 +-
.../wasm-udf-inline}/build.rs | 0
.../wasm-udf-inline}/src/main.rs | 0
{wasm-udfs => examples/wasm-udfs}/Cargo.toml | 12 +-
{wasm-udfs => examples/wasm-udfs}/build.rs | 0
{wasm-udfs => examples/wasm-udfs}/src/lib.rs | 0
pre-commit.sh | 86 ----------
python/pyspark_wasm_udf/pack.py | 8 +-
wasm_udf_example.txt | 175 ---------------------
39 files changed, 237 insertions(+), 448 deletions(-)
diff --git a/.github/workflows/build_python_connect.yml
b/.github/workflows/build_python_connect.yml
index d045828..1f5f1f0 100644
--- a/.github/workflows/build_python_connect.yml
+++ b/.github/workflows/build_python_connect.yml
@@ -64,11 +64,21 @@ jobs:
java-version: "17"
- name: Install Rust toolchain
- uses: actions-rust-lang/setup-rust-toolchain@v1
+ # rustup is preinstalled; avoid third-party toolchain actions (ASF
Actions
+ # policy allowlists actions/* only).
+ run: |
+ rustup toolchain install stable --profile minimal
+ rustup default stable
+
+ - name: Cache cargo & target
+ uses: actions/cache@v4
with:
- toolchain: stable
- rustflags: ""
- cache: true
+ path: |
+ ~/.cargo/registry
+ ~/.cargo/git
+ target
+ key: ${{ runner.os }}-cargo-parity-${{ hashFiles('**/Cargo.lock') }}
+ restore-keys: ${{ runner.os }}-cargo-parity-
- name: Install system dependencies
run: |
@@ -206,11 +216,19 @@ jobs:
with:
distribution: temurin
java-version: "17"
- - uses: actions-rust-lang/setup-rust-toolchain@v1
+ - name: Install Rust toolchain
+ run: |
+ rustup toolchain install stable --profile minimal
+ rustup default stable
+ - name: Cache cargo & target
+ uses: actions/cache@v4
with:
- toolchain: stable
- rustflags: ""
- cache: true
+ path: |
+ ~/.cargo/registry
+ ~/.cargo/git
+ target
+ key: ${{ runner.os }}-cargo-bench-${{ hashFiles('**/Cargo.lock') }}
+ restore-keys: ${{ runner.os }}-cargo-bench-
- name: Install deps
run: |
sudo apt-get update && sudo apt-get install -y protobuf-compiler
@@ -278,17 +296,25 @@ jobs:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- - uses: actions-rust-lang/setup-rust-toolchain@v1
+ - name: Install Rust toolchain
+ run: |
+ rustup toolchain install stable --profile minimal
+ rustup default stable
+ - name: Cache cargo & target
+ uses: actions/cache@v4
with:
- toolchain: stable
- rustflags: ""
- cache: true
+ path: |
+ ~/.cargo/registry
+ ~/.cargo/git
+ target
+ key: ${{ runner.os }}-cargo-checks-${{ hashFiles('**/Cargo.lock') }}
+ restore-keys: ${{ runner.os }}-cargo-checks-
- name: Install system dependencies
run: |
sudo apt-get update && sudo apt-get install -y protobuf-compiler
- name: Cargo build
run: cargo build --release
- name: Cargo test
- run: cargo test -p spark-connect -p spark-connect-core --release
+ run: cargo test -p apache-spark-connect -p apache-spark-connect-core
--release
- name: No-stub audit
run: bash scripts/audit_no_stubs.sh
diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml
index 422fba0..9569a6c 100644
--- a/.github/workflows/lint.yml
+++ b/.github/workflows/lint.yml
@@ -32,12 +32,22 @@ jobs:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- - uses: actions-rust-lang/setup-rust-toolchain@v1
+ - name: Install Rust toolchain (rustfmt + clippy)
+ # rustup is preinstalled; avoid third-party toolchain actions (ASF
Actions
+ # policy allowlists actions/* only).
+ run: |
+ rustup toolchain install stable --profile minimal
+ rustup default stable
+ rustup component add rustfmt clippy
+ - name: Cache cargo & target
+ uses: actions/cache@v4
with:
- toolchain: stable
- rustflags: ""
- components: rustfmt, clippy
- cache: true
+ path: |
+ ~/.cargo/registry
+ ~/.cargo/git
+ target
+ key: ${{ runner.os }}-cargo-lint-${{ hashFiles('**/Cargo.lock') }}
+ restore-keys: ${{ runner.os }}-cargo-lint-
- name: Install system dependencies
run: |
sudo apt-get update && sudo apt-get install -y protobuf-compiler
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
index d6d0288..0f99673 100644
--- a/.github/workflows/release.yml
+++ b/.github/workflows/release.yml
@@ -78,12 +78,12 @@ jobs:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- - uses: dtolnay/rust-toolchain@stable
+ - name: Install Rust toolchain
+ run: |
+ rustup toolchain install stable --profile minimal
+ rustup default stable
- name: Install protoc
- uses: arduino/setup-protoc@v3
- with:
- version: "23.x"
- repo-token: ${{ secrets.GITHUB_TOKEN }}
+ run: sudo apt-get update && sudo apt-get install -y protobuf-compiler
- name: cargo publish (dependency order)
env:
CARGO_REGISTRY_TOKEN: ${{ secrets.CARGO_REGISTRY_TOKEN }}
@@ -110,19 +110,24 @@ jobs:
- uses: actions/setup-python@v5
with:
python-version: "3.11"
- - uses: dtolnay/rust-toolchain@stable
+ - name: Install Rust toolchain
+ run: |
+ rustup toolchain install stable --profile minimal
+ rustup default stable
- name: Install protoc
- uses: arduino/setup-protoc@v3
- with:
- version: "23.x"
- repo-token: ${{ secrets.GITHUB_TOKEN }}
+ shell: bash
+ run: |
+ case "$RUNNER_OS" in
+ Linux) sudo apt-get update && sudo apt-get install -y
protobuf-compiler ;;
+ macOS) brew install protobuf ;;
+ Windows) choco install protoc -y ;;
+ esac
- name: Build abi3 wheel with maturin
# abi3-py39 produces a single wheel per platform valid for CPython
3.9+.
- uses: PyO3/maturin-action@v1
- with:
- command: build
- args: --release --out dist
- manylinux: auto
+ shell: bash
+ run: |
+ python -m pip install --upgrade maturin
+ maturin build --release --out dist
- uses: actions/upload-artifact@v4
with:
name: wheels-${{ matrix.os }}
@@ -134,15 +139,20 @@ jobs:
if: github.event_name == 'push' || (github.event_name ==
'workflow_dispatch' && inputs.dry_run == false)
runs-on: ubuntu-latest
steps:
+ - uses: actions/setup-python@v5
+ with:
+ python-version: "3.11"
- uses: actions/download-artifact@v4
with:
pattern: wheels-*
path: dist
merge-multiple: true
- name: Publish to PyPI
- uses: pypa/gh-action-pypi-publish@release/v1
- with:
- packages-dir: dist
- # Fork-based release: authenticate with the committer's PyPI token
- # rather than repository-scoped Trusted Publishing.
- password: ${{ secrets.PYPI_API_TOKEN }}
+ # Fork-based release: authenticate with the committer's PyPI token via
twine
+ # (avoids third-party publish actions; ASF Actions policy allowlists
actions/*).
+ env:
+ TWINE_USERNAME: __token__
+ TWINE_PASSWORD: ${{ secrets.PYPI_API_TOKEN }}
+ run: |
+ python -m pip install --upgrade twine
+ twine upload dist/*
diff --git a/.github/workflows/rust.yml b/.github/workflows/rust.yml
index d2e4979..bb1d7e0 100644
--- a/.github/workflows/rust.yml
+++ b/.github/workflows/rust.yml
@@ -36,11 +36,22 @@ jobs:
- uses: actions/checkout@v4
- name: Install Rust toolchain
- uses: actions-rust-lang/setup-rust-toolchain@v1
+ # rustup is preinstalled on the runner; use it directly so we depend
only on
+ # GitHub-authored actions (ASF Actions policy allowlists actions/*, not
+ # third-party toolchain actions).
+ run: |
+ rustup toolchain install ${{ matrix.rust-version }} --profile minimal
+ rustup default ${{ matrix.rust-version }}
+
+ - name: Cache cargo & target
+ uses: actions/cache@v4
with:
- toolchain: ${{ matrix.rust-version }}
- rustflags: ""
- cache: true
+ path: |
+ ~/.cargo/registry
+ ~/.cargo/git
+ target
+ key: ${{ runner.os }}-cargo-rust-${{ hashFiles('**/Cargo.lock') }}
+ restore-keys: ${{ runner.os }}-cargo-rust-
- name: Install system dependencies
run: |
@@ -74,12 +85,20 @@ jobs:
- uses: actions/checkout@v4
- name: Install Rust toolchain (+ wasm32 target)
- uses: actions-rust-lang/setup-rust-toolchain@v1
+ run: |
+ rustup toolchain install stable --profile minimal
+ rustup default stable
+ rustup target add wasm32-unknown-unknown
+
+ - name: Cache cargo & target
+ uses: actions/cache@v4
with:
- toolchain: stable
- target: wasm32-unknown-unknown
- rustflags: ""
- cache: true
+ path: |
+ ~/.cargo/registry
+ ~/.cargo/git
+ target
+ key: ${{ runner.os }}-cargo-wasm-${{ hashFiles('**/Cargo.lock') }}
+ restore-keys: ${{ runner.os }}-cargo-wasm-
- name: Install system dependencies
run: |
@@ -98,11 +117,20 @@ jobs:
- name: Build the macro/build helpers and WASM UDF crates + examples
run: |
cargo build -p apache-spark-connect-macros -p
apache-spark-connect-build
- cargo build --manifest-path wasm-udfs/Cargo.toml
- cargo build --manifest-path wasm-udf-inline/Cargo.toml
+ cargo build --manifest-path examples/wasm-udfs/Cargo.toml
+ cargo build --manifest-path examples/wasm-udf-inline/Cargo.toml
cargo build -p examples --bin wasm_udf_macro --features
examples/wasm-udf-macro
- name: Packer end-to-end (Rust -> cloudpickle command)
+ # The packer runs `python -m pyspark_wasm_udf.pack`, which imports the
+ # reference `pyspark` (installed above) for `pyspark.cloudpickle` and
+ # `pyspark.sql.types._parse_datatype_json_value`, and produces a
+ # reference `DataType` the executors unpickle. Put *only* the
+ # `pyspark_wasm_udf` package on the packer path (not ./python, whose
+ # `pyspark` skin is Rust-backed and would shadow the reference).
env:
- SPARK_CONNECT_WASM_PACKER_PATH: ${{ github.workspace }}/python
- run: cargo test -p apache-spark-connect --features wasm-udf --
--ignored packer_produces
+ SPARK_CONNECT_WASM_PACKER_PATH: ${{ github.workspace }}/.packer
+ run: |
+ mkdir -p .packer
+ cp -r python/pyspark_wasm_udf .packer/
+ cargo test -p apache-spark-connect --features wasm-udf -- --ignored
packer_produces
diff --git a/.gitignore b/.gitignore
index c59e0e6..d598624 100644
--- a/.gitignore
+++ b/.gitignore
@@ -16,6 +16,9 @@
# under the License.
/target
+# The workspace-excluded WASM example crates build to their own target/ dirs.
+/examples/wasm-udfs/target
+/examples/wasm-udf-inline/target
**/*.rs.bk
Cargo.lock
__pycache__/
diff --git a/.isaac/config.json b/.isaac/config.json
new file mode 100644
index 0000000..93cf0a6
--- /dev/null
+++ b/.isaac/config.json
@@ -0,0 +1,3 @@
+{
+ "sync_reminder_last_shown": "2026-08-25"
+}
\ No newline at end of file
diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml
deleted file mode 100644
index 7d96998..0000000
--- a/.pre-commit-config.yaml
+++ /dev/null
@@ -1,42 +0,0 @@
-# Licensed to the Apache Software Foundation (ASF) under one
-# or more contributor license agreements. See the NOTICE file
-# distributed with this work for additional information
-# regarding copyright ownership. The ASF licenses this file
-# to you under the Apache License, Version 2.0 (the
-# "License"); you may not use this file except in compliance
-# with the License. You may obtain a copy of the License at
-#
-# http://www.apache.org/licenses/LICENSE-2.0
-#
-# Unless required by applicable law or agreed to in writing,
-# software distributed under the License is distributed on an
-# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
-# KIND, either express or implied. See the License for the
-# specific language governing permissions and limitations
-# under the License.
-
-repos:
- - repo: https://github.com/pre-commit/pre-commit-hooks
- rev: v4.4.0
- hooks:
- - id: check-byte-order-marker
- - id: check-case-conflict
- - id: check-merge-conflict
- - id: check-symlinks
- - id: check-yaml
- - id: end-of-file-fixer
- - id: mixed-line-ending
- - id: trailing-whitespace
- - repo: https://github.com/doublify/pre-commit-rust
- rev: v1.0
- hooks:
- - id: fmt
- - id: cargo-check
- - id: clippy
- - repo: https://github.com/astral-sh/ruff-pre-commit
- rev: v0.14.0
- hooks:
- - id: ruff-check
- args: [--fix, "scripts/"]
- - id: ruff-format
- args: ["scripts/"]
diff --git a/Cargo.lock b/Cargo.lock
index 661af7c..2e4c3c4 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -1482,9 +1482,9 @@ dependencies = [
[[package]]
name = "ethnum"
-version = "1.5.0"
+version = "1.5.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "b90ca2580b73ab6a1f724b76ca11ab632df820fd6040c336200d2c1df7b3c82c"
+checksum = "40404c3f5f511ec4da6fe866ddf6a717c309fdbb69fbbad7b0f3edab8f2e835f"
[[package]]
name = "event-listener"
diff --git a/Cargo.toml b/Cargo.toml
index 563bd9d..10801e0 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -27,19 +27,20 @@ members = [
"examples",
]
exclude = [
- "wasm-udfs",
- "wasm-udf-inline",
+ "examples/wasm-udfs",
+ "examples/wasm-udf-inline",
]
# `pyspark-rs` (the CPython extension cdylib, built via maturin) and the
# Rust-native `examples` crate both link the pure-Rust core directly; both are
# workspace members but excluded from `default-members` so `cargo test`/`cargo
build`
# stay clean. Build them explicitly (`cargo build -p pyspark-rs` / `-p
examples`).
#
-# The `wasm-udfs` and `wasm-udf-inline` crates are excluded from the workspace
-# entirely because they require the `wasm32-unknown-unknown` target, which may
not
-# be installed on every machine (so `cargo test --workspace` stays green
without it).
-# Build them explicitly with `cargo build --manifest-path
wasm-udfs/Cargo.toml` (etc)
-# in CI or when the target is installed locally.
+# The `examples/wasm-udfs` and `examples/wasm-udf-inline` crates are excluded
from
+# the workspace entirely because they require the `wasm32-unknown-unknown`
target,
+# which may not be installed on every machine (so `cargo test --workspace`
stays
+# green without it). Build them explicitly with
+# `cargo build --manifest-path examples/wasm-udfs/Cargo.toml` (etc) in CI or
when
+# the target is installed locally.
default-members = [
"crates/spark-connect-proto",
"crates/spark-connect-core",
diff --git a/README.md b/README.md
index d3f8d54..ffbfc20 100644
--- a/README.md
+++ b/README.md
@@ -29,14 +29,6 @@ building, transport, and Arrow decoding done in Rust.
version tracks the Spark release it targets (starting at `4.2.0`), so the
version
number tells you which Spark it speaks.
-> **Status: alpha, work in progress.** The core DataFrame/Column/functions API,
-> transport, and Arrow result path work end-to-end against a real Spark Connect
-> server. The crates and wheel carry the version **4.2.0** to track the Apache
Spark
-> release they target — it is a compatibility marker, not a maturity claim;
treat the
-> client itself as alpha. API parity with the reference client is validated by
the
-> golden-proto tests (plan building) and the official Apache Spark connect
test suite
-> (transport + Arrow); see [Continuous integration](#continuous-integration).
-
## Why
The reference Spark Connect client is pure Python: it builds protobuf plans,
@@ -134,21 +126,13 @@ fn main() -> Result<()> {
Python UDFs run as normal. In addition, a Rust function can be compiled to
WebAssembly and shipped as a UDF: the `.wasm` bytes are embedded in a
cloudpickled
Python shim and executed on the worker via `wasmtime`, so no Rust toolchain is
-needed server-side. See `python/pyspark_wasm_udf/` and
`examples/src/wasm_udf.rs`.
+needed server-side. See `python/pyspark_wasm_udf/` and the example crates
+`examples/wasm-udf-inline/` (single-file) and `examples/wasm-udfs/` +
+`examples/src/wasm_udf_macro.rs` (reusable crate).
## Getting started with Spark Connect server
-You can run a Spark Connect server locally for development in two ways:
-
-### Option 1: Docker Compose (recommended)
-
-The repo includes a `docker-compose.yml` that starts a Spark Connect server on
port 15002:
-
-```bash
-docker compose up --build -d
-```
-
-### Option 2: Local Spark distribution
+Run a Spark Connect server locally for development:
1. [Download Spark 4.2.0](https://spark.apache.org/downloads.html) and unzip it
2. Set `SPARK_HOME` to the unzipped directory
@@ -166,8 +150,11 @@ The server listens on `sc://localhost:15002` by default.
### Sample data
-The repo includes sample datasets in `datasets/` (people.csv, employees.json,
kv1.txt, etc.)
-mounted at `/opt/spark/work-dir/datasets` in the Docker container for use in
tests and examples.
+Sample datasets used by the examples live in `examples/datasets/` (people.csv,
+employees.json, kv1.txt, etc.). The reader/deltalake examples load them by a
path
+relative to the **server's** working directory (e.g. `./datasets/people.csv`),
so
+start the Spark Connect server from the `examples/` directory (where
`./datasets`
+resolves), or adjust the path in the example to point at the data.
## Continuous integration
diff --git a/crates/pyspark-rs/src/transport.rs
b/crates/pyspark-rs/src/transport.rs
index 8484a91..169c93a 100644
--- a/crates/pyspark-rs/src/transport.rs
+++ b/crates/pyspark-rs/src/transport.rs
@@ -21,6 +21,7 @@ use tokio::sync::Notify;
use spark_connect_core::channel::ChannelBuilder;
use spark_connect_core::client::SparkConnectClient;
+use spark_connect_core::retries::RetryPolicy;
use spark_connect_core::runtime::block_on;
use spark_connect_proto as proto;
@@ -166,9 +167,15 @@ impl RustConnectStub {
let builder = ChannelBuilder::parse(url).map_err(err)?;
// Release the GIL during the connect handshake so a slow/unresponsive
server
// can't wedge the whole interpreter (incl. signal handling / Ctrl-C).
- let client = py
+ let mut client = py
.detach(|| block_on(SparkConnectClient::connect(&builder)))
.map_err(err)?;
+ // This stub is a drop-in for the grpcio stub: a single RPC per call.
The reference
+ // client wraps calls in its own GrpcRetryHandler (per the client-side
retry
+ // policy), so retrying inside our client too would double-retry and
ignore that
+ // policy - a `max_retries=0` client would otherwise hit our 15-retry
backoff and
+ // hang against an unreachable server instead of failing fast with
UNAVAILABLE.
+ client.set_retry_policy(RetryPolicy::no_retries());
Ok(Self {
client,
cancel: Arc::new(Notify::new()),
diff --git a/crates/spark-connect-core/src/client.rs
b/crates/spark-connect-core/src/client.rs
index 6064b61..70438fa 100644
--- a/crates/spark-connect-core/src/client.rs
+++ b/crates/spark-connect-core/src/client.rs
@@ -23,6 +23,28 @@ use crate::error::{Result, SparkError};
use crate::reattach::ExecutePlanResponseReattachableIterator;
use crate::retries::{RetryPolicy, RetryPolicyState};
+/// How long to wait for the lazy gRPC channel to become ready before
surfacing a
+/// retriable `UNAVAILABLE`. tonic's lazy channel waits indefinitely for an
unreachable
+/// or unresolvable host (unlike grpcio, which fails fast), so without this
bound a call
+/// to a down server would hang forever instead of raising `UNAVAILABLE`.
+const READY_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(10);
+
+/// Wait for `grpc` to become ready, bounded by [`READY_TIMEOUT`], mapping any
failure
+/// (or the timeout) to a gRPC `UNAVAILABLE` error. This matches how grpcio
reports an
+/// unreachable server, so the reference client raises
`SparkConnectGrpcException` with
+/// `UNAVAILABLE` rather than hanging on a channel that never connects.
+async fn wait_ready(grpc: &mut tonic::client::Grpc<Channel>) -> Result<()> {
+ match tokio::time::timeout(READY_TIMEOUT, grpc.ready()).await {
+ Ok(Ok(())) => Ok(()),
+ Ok(Err(e)) =>
Err(SparkError::from_grpc_status(tonic::Status::unavailable(
+ format!("channel not ready: {e}"),
+ ))),
+ Err(_elapsed) =>
Err(SparkError::from_grpc_status(tonic::Status::unavailable(
+ "channel not ready: connection timed out",
+ ))),
+ }
+}
+
/// A Spark Connect client for communicating with a remote Spark server.
///
/// Mirrors `pyspark.sql.connect.client.core.SparkConnectClient`.
@@ -71,11 +93,13 @@ impl SparkConnectClient {
.map_err(|e| SparkError::connect_msg(format!("Invalid
endpoint: {}", e)))?
.tls_config(tls_config)
.map_err(|e| SparkError::connect_msg(format!("Failed to
configure TLS: {}", e)))?
+ .connect_timeout(READY_TIMEOUT)
.connect_lazy()
} else {
// Plaintext connection (for localhost development)
tonic::transport::Channel::from_shared(format!("http://{endpoint}"))
.map_err(|e| SparkError::connect_msg(format!("Invalid
endpoint: {}", e)))?
+ .connect_timeout(READY_TIMEOUT)
.connect_lazy()
};
@@ -124,6 +148,15 @@ impl SparkConnectClient {
self.user_id.as_deref()
}
+ /// Override the retry policy applied to this client's RPCs.
+ ///
+ /// The transport-injection stub sets this to [`RetryPolicy::no_retries`]
so it behaves
+ /// like the single-shot grpcio stub the reference client expects (which
does its own
+ /// retrying on top); see that constructor for why double-retrying is
wrong there.
+ pub fn set_retry_policy(&mut self, policy: RetryPolicy) {
+ self.retry_policy = policy;
+ }
+
/// Execute a plan and return a stream of responses.
///
/// Mirrors
`pyspark.sql.connect.client.core.SparkConnectClient.execute_plan`.
@@ -145,9 +178,7 @@ impl SparkConnectClient {
/// the reference client built.
pub async fn execute_plan_raw(&self, request: Vec<u8>) ->
Result<Streaming<Vec<u8>>> {
let mut grpc = tonic::client::Grpc::new(self.channel.clone());
- grpc.ready()
- .await
- .map_err(|e| SparkError::connect_msg(format!("channel not ready:
{e}")))?;
+ wait_ready(&mut grpc).await?;
let mut req = Request::new(request);
self._attach_metadata(&mut req);
let path = tonic::codegen::http::uri::PathAndQuery::from_static(
@@ -163,9 +194,7 @@ impl SparkConnectClient {
/// ReattachExecute, byte-exact passthrough returning a raw response
stream.
pub async fn reattach_execute_raw(&self, request: Vec<u8>) ->
Result<Streaming<Vec<u8>>> {
let mut grpc = tonic::client::Grpc::new(self.channel.clone());
- grpc.ready()
- .await
- .map_err(|e| SparkError::connect_msg(format!("channel not ready:
{e}")))?;
+ wait_ready(&mut grpc).await?;
let mut req = Request::new(request);
self._attach_metadata(&mut req);
let path = tonic::codegen::http::uri::PathAndQuery::from_static(
@@ -215,9 +244,7 @@ impl SparkConnectClient {
/// reference client, which never re-decodes the request it built).
pub async fn analyze_plan_raw(&self, request: Vec<u8>) -> Result<Vec<u8>> {
let mut grpc = tonic::client::Grpc::new(self.channel.clone());
- grpc.ready()
- .await
- .map_err(|e| SparkError::connect_msg(format!("channel not ready:
{e}")))?;
+ wait_ready(&mut grpc).await?;
let mut req = Request::new(request);
self._attach_metadata(&mut req);
let path = tonic::codegen::http::uri::PathAndQuery::from_static(
diff --git a/crates/spark-connect-core/src/retries.rs
b/crates/spark-connect-core/src/retries.rs
index 8c749f7..649d3b1 100644
--- a/crates/spark-connect-core/src/retries.rs
+++ b/crates/spark-connect-core/src/retries.rs
@@ -51,6 +51,19 @@ impl Default for RetryPolicy {
}
impl RetryPolicy {
+ /// A policy that performs a single attempt with no retries.
+ ///
+ /// Used by the transport-injection stub, which is a drop-in for the
grpcio stub: the
+ /// reference client layers its own `GrpcRetryHandler` (configured by the
client-side
+ /// retry policy) on top, so retrying here too would double-retry and
ignore that
+ /// policy - e.g. a `max_retries=0` client would still see our 15-retry
backoff.
+ pub fn no_retries() -> Self {
+ Self {
+ max_retries: Some(0),
+ ..Self::default()
+ }
+ }
+
/// Whether a failed RPC is retryable under this policy.
///
/// Mirrors `pyspark.sql.connect.client.retries.DefaultPolicy.can_retry`:
diff --git a/crates/spark-connect/src/wasm_udf.rs
b/crates/spark-connect/src/wasm_udf.rs
index a4167b4..74d8407 100644
--- a/crates/spark-connect/src/wasm_udf.rs
+++ b/crates/spark-connect/src/wasm_udf.rs
@@ -537,9 +537,11 @@ mod tests {
}
#[test]
- fn parse_python_version_output() {
+ fn parse_python_version_output_trims_whitespace() {
// Test that we correctly parse Python version strings from stdout
- // (with potential trailing whitespace/newline)
+ // (with potential trailing whitespace/newline). Note: this test must
not be
+ // named `parse_python_version_output`, or it would shadow the
function under
+ // test (imported via `use super::*`) and fail to compile.
assert_eq!(
parse_python_version_output("3.11\n"),
Some("3.11".to_string())
diff --git a/docker-compose.yml b/docker-compose.yml
deleted file mode 100644
index 8938665..0000000
--- a/docker-compose.yml
+++ /dev/null
@@ -1,33 +0,0 @@
-# Licensed to the Apache Software Foundation (ASF) under one
-# or more contributor license agreements. See the NOTICE file
-# distributed with this work for additional information
-# regarding copyright ownership. The ASF licenses this file
-# to you under the Apache License, Version 2.0 (the
-# "License"); you may not use this file except in compliance
-# with the License. You may obtain a copy of the License at
-#
-# http://www.apache.org/licenses/LICENSE-2.0
-#
-# Unless required by applicable law or agreed to in writing,
-# software distributed under the License is distributed on an
-# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
-# KIND, either express or implied. See the License for the
-# specific language governing permissions and limitations
-# under the License.
-
-services:
- spark:
- image: "apache/spark:4.2.0"
- command: >
- /opt/spark/sbin/start-connect-server.sh
- --packages
"org.apache.spark:spark-connect_2.13:4.2.0,io.delta:delta-spark_2.13:4.2.0"
- --conf "spark.driver.extraJavaOptions=-Divy.cache.dir=/tmp
-Divy.home=/tmp"
- --conf "spark.sql.extensions=io.delta.sql.DeltaSparkSessionExtension"
- --conf
"spark.sql.catalog.spark_catalog=org.apache.spark.sql.delta.catalog.DeltaCatalog"
- environment:
- - SPARK_NO_DAEMONIZE=true
- ports:
- - "4040:4040"
- - "15002:15002"
- volumes:
- - ./datasets:/opt/spark/work-dir/datasets
diff --git a/examples/Cargo.toml b/examples/Cargo.toml
index 8f0bbba..1c389eb 100644
--- a/examples/Cargo.toml
+++ b/examples/Cargo.toml
@@ -56,7 +56,7 @@ required-features = ["wasm-udf-macro"]
[dependencies]
spark-connect = { package = "apache-spark-connect", path =
"../crates/spark-connect" }
-wasm-udfs = { path = "../wasm-udfs", optional = true }
+wasm-udfs = { path = "wasm-udfs", optional = true }
[features]
# Enable the macro example (`wasm_udf_macro` bin). Requires the
diff --git a/examples/README.md b/examples/README.md
index 35e63a3..e84da40 100644
--- a/examples/README.md
+++ b/examples/README.md
@@ -65,10 +65,8 @@ cargo run --bin readstream
Read a file into a dataframe, save the result as a deltalake table, and append
a new record to the table.
-**Prerequisite** the spark cluster must be started with the deltalake package.
The `docker-compose.yml` provided in the repo has deltalake pre-installed.
-Or if you are running a spark connect server location, run the below scripts
first
-
-If you are running a local spark connect server. The Delta Lake jars need to
be added onto the server before it starts.
+**Prerequisite** the spark cluster must be started with the deltalake package.
+The Delta Lake jars need to be added onto the server before it starts:
```bash
$ $SPARK_HOME/sbin/start-connect-server.sh --packages
"org.apache.spark:spark-connect_2.13:4.2.0,io.delta:delta-spark_2.13:4.2.0" \
diff --git a/datasets/dir1/dir2/file2.parquet
b/examples/datasets/dir1/dir2/file2.parquet
similarity index 100%
rename from datasets/dir1/dir2/file2.parquet
rename to examples/datasets/dir1/dir2/file2.parquet
diff --git a/datasets/dir1/file1.parquet b/examples/datasets/dir1/file1.parquet
similarity index 100%
rename from datasets/dir1/file1.parquet
rename to examples/datasets/dir1/file1.parquet
diff --git a/datasets/dir1/file3.json b/examples/datasets/dir1/file3.json
similarity index 100%
rename from datasets/dir1/file3.json
rename to examples/datasets/dir1/file3.json
diff --git a/datasets/employees.json b/examples/datasets/employees.json
similarity index 100%
rename from datasets/employees.json
rename to examples/datasets/employees.json
diff --git a/datasets/full_user.avsc b/examples/datasets/full_user.avsc
similarity index 100%
rename from datasets/full_user.avsc
rename to examples/datasets/full_user.avsc
diff --git a/datasets/kv1.txt b/examples/datasets/kv1.txt
similarity index 100%
rename from datasets/kv1.txt
rename to examples/datasets/kv1.txt
diff --git a/datasets/people.csv b/examples/datasets/people.csv
similarity index 100%
rename from datasets/people.csv
rename to examples/datasets/people.csv
diff --git a/datasets/people.json b/examples/datasets/people.json
similarity index 100%
rename from datasets/people.json
rename to examples/datasets/people.json
diff --git a/datasets/people.txt b/examples/datasets/people.txt
similarity index 100%
rename from datasets/people.txt
rename to examples/datasets/people.txt
diff --git a/datasets/user.avsc b/examples/datasets/user.avsc
similarity index 100%
rename from datasets/user.avsc
rename to examples/datasets/user.avsc
diff --git a/datasets/users.avro b/examples/datasets/users.avro
similarity index 100%
rename from datasets/users.avro
rename to examples/datasets/users.avro
diff --git a/datasets/users.orc b/examples/datasets/users.orc
similarity index 100%
rename from datasets/users.orc
rename to examples/datasets/users.orc
diff --git a/datasets/users.parquet b/examples/datasets/users.parquet
similarity index 100%
rename from datasets/users.parquet
rename to examples/datasets/users.parquet
diff --git a/wasm-udf-inline/Cargo.toml b/examples/wasm-udf-inline/Cargo.toml
similarity index 73%
rename from wasm-udf-inline/Cargo.toml
rename to examples/wasm-udf-inline/Cargo.toml
index 4ff7f75..68f5dcd 100644
--- a/wasm-udf-inline/Cargo.toml
+++ b/examples/wasm-udf-inline/Cargo.toml
@@ -18,6 +18,12 @@
# Single-file WASM UDF example: the UDF *and* the client live in `src/main.rs`.
# `build.rs` (via the spark-connect-build helper) compiles that same file to a
# wasm32 module and embeds it.
+
+# Standalone crate with its own workspace root: it lives under `examples/` but
is
+# excluded from the root workspace because it targets `wasm32-unknown-unknown`.
+# (Nothing in the root workspace depends on it, so its own `[workspace]` is
safe.)
+[workspace]
+
[package]
name = "wasm-udf-inline"
version = "0.0.0"
@@ -25,16 +31,16 @@ edition = "2021"
publish = false
[dependencies]
-spark-connect-macros = { package = "apache-spark-connect-macros", path =
"../crates/spark-connect-macros", version = "4.2.0" }
+spark-connect-macros = { package = "apache-spark-connect-macros", path =
"../../crates/spark-connect-macros", version = "4.2.0" }
# Host-only: the client + the macro-generated constructors (which need the
# `wasm-udf` feature). Never compiled for wasm32.
[target.'cfg(not(target_arch = "wasm32"))'.dependencies]
-spark-connect = { package = "apache-spark-connect", path =
"../crates/spark-connect", version = "4.2.0", features = ["wasm-udf"] }
+spark-connect = { package = "apache-spark-connect", path =
"../../crates/spark-connect", version = "4.2.0", features = ["wasm-udf"] }
[build-dependencies]
# The build helper that compiles + embeds the wasm module.
-spark-connect-build = { package = "apache-spark-connect-build", path =
"../crates/spark-connect-build", version = "4.2.0" }
+spark-connect-build = { package = "apache-spark-connect-build", path =
"../../crates/spark-connect-build", version = "4.2.0" }
# Forces the proc-macro to be built before build.rs runs, so the helper can
find
# its dylib to feed the wasm `rustc` invocation.
-spark-connect-macros = { package = "apache-spark-connect-macros", path =
"../crates/spark-connect-macros", version = "4.2.0" }
+spark-connect-macros = { package = "apache-spark-connect-macros", path =
"../../crates/spark-connect-macros", version = "4.2.0" }
diff --git a/wasm-udf-inline/build.rs b/examples/wasm-udf-inline/build.rs
similarity index 100%
rename from wasm-udf-inline/build.rs
rename to examples/wasm-udf-inline/build.rs
diff --git a/wasm-udf-inline/src/main.rs b/examples/wasm-udf-inline/src/main.rs
similarity index 100%
rename from wasm-udf-inline/src/main.rs
rename to examples/wasm-udf-inline/src/main.rs
diff --git a/wasm-udfs/Cargo.toml b/examples/wasm-udfs/Cargo.toml
similarity index 71%
rename from wasm-udfs/Cargo.toml
rename to examples/wasm-udfs/Cargo.toml
index 67ff354..73e1e3f 100644
--- a/wasm-udfs/Cargo.toml
+++ b/examples/wasm-udfs/Cargo.toml
@@ -18,6 +18,10 @@
# A reusable UDF crate: `#[spark_wasm_udf]` functions whose `<name>_udf()`
# constructors other crates can call. `build.rs` compiles this crate's own
# functions to wasm32 and embeds the module.
+#
+# No own `[workspace]` table: this crate is a path-dependency of the `examples`
+# member, so it must resolve within the root workspace (which `exclude`s it
from
+# membership). It builds standalone via `--manifest-path` because it's
excluded.
[package]
name = "wasm-udfs"
version = "0.0.0"
@@ -25,13 +29,13 @@ edition = "2021"
publish = false
[dependencies]
-spark-connect-macros = { package = "apache-spark-connect-macros", path =
"../crates/spark-connect-macros", version = "4.2.0" }
+spark-connect-macros = { package = "apache-spark-connect-macros", path =
"../../crates/spark-connect-macros", version = "4.2.0" }
# Host-only: the macro-generated constructors (which need the `wasm-udf`
# feature). Never compiled for wasm32.
[target.'cfg(not(target_arch = "wasm32"))'.dependencies]
-spark-connect = { package = "apache-spark-connect", path =
"../crates/spark-connect", version = "4.2.0", features = ["wasm-udf"] }
+spark-connect = { package = "apache-spark-connect", path =
"../../crates/spark-connect", version = "4.2.0", features = ["wasm-udf"] }
[build-dependencies]
-spark-connect-build = { package = "apache-spark-connect-build", path =
"../crates/spark-connect-build", version = "4.2.0" }
-spark-connect-macros = { package = "apache-spark-connect-macros", path =
"../crates/spark-connect-macros", version = "4.2.0" }
+spark-connect-build = { package = "apache-spark-connect-build", path =
"../../crates/spark-connect-build", version = "4.2.0" }
+spark-connect-macros = { package = "apache-spark-connect-macros", path =
"../../crates/spark-connect-macros", version = "4.2.0" }
diff --git a/wasm-udfs/build.rs b/examples/wasm-udfs/build.rs
similarity index 100%
rename from wasm-udfs/build.rs
rename to examples/wasm-udfs/build.rs
diff --git a/wasm-udfs/src/lib.rs b/examples/wasm-udfs/src/lib.rs
similarity index 100%
rename from wasm-udfs/src/lib.rs
rename to examples/wasm-udfs/src/lib.rs
diff --git a/pre-commit.sh b/pre-commit.sh
deleted file mode 100644
index f82390e..0000000
--- a/pre-commit.sh
+++ /dev/null
@@ -1,86 +0,0 @@
-#!/bin/bash
-
-# Licensed to the Apache Software Foundation (ASF) under one
-# or more contributor license agreements. See the NOTICE file
-# distributed with this work for additional information
-# regarding copyright ownership. The ASF licenses this file
-# to you under the Apache License, Version 2.0 (the
-# "License"); you may not use this file except in compliance
-# with the License. You may obtain a copy of the License at
-#
-# http://www.apache.org/licenses/LICENSE-2.0
-#
-# Unless required by applicable law or agreed to in writing,
-# software distributed under the License is distributed on an
-# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
-# KIND, either express or implied. See the License for the
-# specific language governing permissions and limitations
-# under the License.
-
-# This file is git pre-commit hook.
-#
-# Soft link it as git hook under top dir of apache arrow git repository:
-# $ ln -s ../../pre-commit.sh .git/hooks/pre-commit
-#
-# This file be run directly:
-# $ ./pre-commit.sh
-
-function RED() {
- echo "\033[0;31m$@\033[0m"
-}
-
-function GREEN() {
- echo "\033[0;32m$@\033[0m"
-}
-
-function BYELLOW() {
- echo "\033[1;33m$@\033[0m"
-}
-
-# env GIT_DIR is set by git when run a pre-commit hook.
-if [ -z "${GIT_DIR}" ]; then
- GIT_DIR=$(git rev-parse --show-toplevel)
-fi
-
-cd ${GIT_DIR}
-
-NUM_CHANGES=$(git diff --cached --name-only . |
- grep -e ".*/*.rs$" |
- awk '{print $1}' |
- wc -l)
-
-if [ ${NUM_CHANGES} -eq 0 ]; then
- echo -e "$(GREEN INFO): no staged changes in *.rs, $(GREEN skip cargo
fmt/clippy)"
- exit 0
-fi
-
-# 1. cargo clippy
-
-echo -e "$(GREEN INFO): cargo clippy ..."
-
-# Cargo clippy always return exit code 0, and `tee` doesn't work.
-# So let's just run cargo clippy.
-cargo clippy
-echo -e "$(GREEN INFO): cargo clippy done"
-
-# 2. cargo fmt: format with nightly and stable.
-
-CHANGED_BY_CARGO_FMT=false
-echo -e "$(GREEN INFO): cargo fmt with nightly and stable ..."
-
-for version in nightly stable; do
- CMD="cargo +${version} fmt"
- ${CMD} --all -q -- --check 2>/dev/null
- if [ $? -ne 0 ]; then
- ${CMD} --all
- echo -e "$(BYELLOW WARN): ${CMD} changed some files"
- CHANGED_BY_CARGO_FMT=true
- fi
-done
-
-if ${CHANGED_BY_CARGO_FMT}; then
- echo -e "$(RED FAIL): git commit $(RED ABORTED), please have a look and
run git add/commit again"
- exit 1
-fi
-
-exit 0
diff --git a/python/pyspark_wasm_udf/pack.py b/python/pyspark_wasm_udf/pack.py
index 9b3c305..a38a7cb 100644
--- a/python/pyspark_wasm_udf/pack.py
+++ b/python/pyspark_wasm_udf/pack.py
@@ -35,10 +35,10 @@ generically with
:func:`pyspark.sql.types._parse_datatype_json_value`, so any
Spark type is supported without a token table.
The command mirrors ``pyspark.sql.connect.expressions.PythonUDF``:
-``cloudpickle.dumps((func, output_type))``. We use the standalone PyPI
-``cloudpickle`` (byte-for-byte the same as ``pyspark.cloudpickle``) and
register
-:mod:`pyspark_wasm_udf` for pickling **by value**, so executors need not have
-this package installed.
+``cloudpickle.dumps((func, output_type))``. We use the bundled
+``pyspark.cloudpickle`` (the same cloudpickle the reference client ships) and
+register :mod:`pyspark_wasm_udf` for pickling **by value**, so executors need
not
+have this package installed.
"""
import base64
diff --git a/wasm_udf_example.txt b/wasm_udf_example.txt
deleted file mode 100644
index e3b2172..0000000
--- a/wasm_udf_example.txt
+++ /dev/null
@@ -1,175 +0,0 @@
-================================================================================
-Rust WASM UDF on Spark — end-user example
-================================================================================
-
-Run a Rust user-defined function on Spark, distributed on the executors, with
no
-server-side plugin. You write plain Rust functions; the toolchain compiles them
-to WebAssembly, embeds the module, and ships it inside a normal Spark
PythonUDF.
-Executors need only the `wasmtime` Python package.
-
-An end user writes THREE small files.
-
-
---------------------------------------------------------------------------------
-1. src/main.rs — write plain Rust functions and use them
---------------------------------------------------------------------------------
-
- use spark_connect_macros::spark_wasm_udf;
-
- // Your UDFs — just plain Rust functions. Annotate the module; that's it.
- #[spark_wasm_udf]
- mod udfs {
- pub fn add_one(x: i64) -> i64 {
- x + 1
- }
-
- pub fn shout(s: String) -> String {
- format!("{}!", s.to_uppercase())
- }
- }
-
- #[cfg(not(target_arch = "wasm32"))]
- fn main() -> Result<(), Box<dyn std::error::Error>> {
- use spark_connect::functions::col;
- use spark_connect::{SparkSession, SparkSessionBuilder};
-
- let spark: SparkSession = SparkSessionBuilder::default()
- .remote("sc://127.0.0.1:15002/")
- .get_or_create()?;
-
- // Pass columns straight in — one per argument. The macro generated
- // udf::add_one / udf::shout: signature inferred, wasm module embedded.
- spark
- .range(5)?
- .select(vec![
- col("id"),
- udf::add_one(col("id"))?.alias("id_plus_one"),
-
udf::shout(col("id").cast(spark_connect::types::DataType::String {
- collation: "UTF8_BINARY".to_string(),
- }))?
- .alias("shouted"),
- ])
- .show(20)?;
-
- Ok(())
- }
-
-
---------------------------------------------------------------------------------
-2. build.rs — one line
---------------------------------------------------------------------------------
-
- fn main() {
- spark_connect_build::embed_wasm_udf("src/main.rs");
- }
-
-
---------------------------------------------------------------------------------
-3. Cargo.toml — dependencies
---------------------------------------------------------------------------------
-
- [dependencies]
- spark-connect-macros = { package = "apache-spark-connect-macros", version
= "4.2" }
-
- # client is host-only; not pulled into the wasm build
- [target.'cfg(not(target_arch = "wasm32"))'.dependencies]
- spark-connect = { package = "apache-spark-connect", version = "4.2",
features = ["wasm-udf"] }
-
- [build-dependencies]
- spark-connect-build = { package = "apache-spark-connect-build", version
= "4.2" }
- spark-connect-macros = { package = "apache-spark-connect-macros", version
= "4.2" }
-
-
---------------------------------------------------------------------------------
-Run it
---------------------------------------------------------------------------------
-
- rustup target add wasm32-unknown-unknown # once
- export SPARK_CONNECT_WASM_PACKER_PATH=$PWD/python # so the client finds
the packer
- cargo run
-
-Output (against a Spark 4.2.0 Connect server on :15002, wasmtime on executors):
-
- +---+-----------+-------+
- | id|id_plus_one|shouted|
- +---+-----------+-------+
- | 0| 1| 0!|
- | 1| 2| 1!|
- | 2| 3| 2!|
- ...
- +---+-----------+-------+
-
-
---------------------------------------------------------------------------------
-The end-user mental model
---------------------------------------------------------------------------------
-
- They write They call They never touch
- ----------------------- ------------------------
------------------------
- fn add_one(x: i64) -> i64 udf::add_one(col("id"))? WASM bytes, the
ABI,
- a .wasm file, the
Spark
- type of the UDF
-
-The macro infers the Spark signature from the Rust types ((Long) -> Long),
-build.rs compiles the SAME source to WASM and embeds it, and the client ships
it
-inside a normal Spark PythonUDF. Arity is checked at compile time: add_one
takes
-one column; a two-arg UDF would be udf::concat(col("a"), col("b"))?.
-
-
---------------------------------------------------------------------------------
-Supported argument / result types (inferred; nest arbitrarily)
---------------------------------------------------------------------------------
-
- Rust Spark SQL type
- ---------- ---------------------
- i32 IntegerType
- i64 LongType
- f32 FloatType
- f64 DoubleType
- bool BooleanType
- String StringType
- Vec<u8> BinaryType
- Vec<T> ArrayType (of T)
- Option<T> nullable T
-
-e.g. Vec<Option<String>> -> ArrayType(StringType, nullable).
-
-
---------------------------------------------------------------------------------
-Two other shapes
---------------------------------------------------------------------------------
-
-- UDFs as a reusable crate, called from any client:
- wasm_udfs::udf::add_one(col("id"))?
- (see wasm-udfs/ + examples/src/wasm_udf_macro.rs)
-
-- Advanced config via the builder accessor:
- udf::add_one_udf().as_nondeterministic().call(vec![col("id")])?
-
-
---------------------------------------------------------------------------------
-Caveat (see review finding #3 on PR #50)
---------------------------------------------------------------------------------
-
-The UDF currently reports Python "3.11" to Spark by default. If your executors
-run a different Python minor version, set it explicitly until the default is
-fixed to derive from the packer's Python:
-
- udf::add_one_udf().with_python_ver("3.10").call(vec![col("id")])?
-
-
---------------------------------------------------------------------------------
-Preconditions (only when using Rust UDFs; nothing here unless `wasm-udf` is on)
---------------------------------------------------------------------------------
-
-- Build machine: wasm32-unknown-unknown target; apache-spark-connect-macros +
- apache-spark-connect-build as (build-)dependencies.
-- Client (building the UDF command): a Python interpreter with `cloudpickle`
- and `pyspark`, plus the repo's python/ directory importable as
- `pyspark_wasm_udf` (SPARK_CONNECT_PYTHON / SPARK_CONNECT_WASM_PACKER_PATH, or
- UserDefinedFunction::with_packer).
-- Executors: the `wasmtime` Python package installed.
-- Spark: 4.2.0+.
-
-The single-file example above lives in the repo at wasm-udf-inline/.
-================================================================================
---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]