viirya commented on PR #49:
URL: 
https://github.com/apache/spark-connect-rust/pull/49#issuecomment-5392367636

   I reviewed this end-to-end (the transport core, the PyO3 bridge, the 
pure-Rust `spark-connect` DataFrame API, and the test harness). This is a 
large, carefully-organized change and the transport seam in particular is 
nicely done — the byte-passthrough stub is a clean way to let the official 
suite exercise the Rust core. My concerns fall into four buckets: a publishing 
blocker, some silent-wrong-result bugs in the plan builder, a gap between what 
the test harness covers and what ships, and repo-governance scope.
   
   ## The crate names are already taken on crates.io, so `release.yml` cannot 
work
   
   This one blocks the release story regardless of code quality:
   
   - **`spark-connect`** — the name this PR gives its flagship crate — is 
already published on crates.io by an unrelated third party (currently 0.2.2, 
`github.com/franciscoabsampaio/spark-connect`).
   - **`spark-connect-core`** is also taken, by the previous architecture's 
author (0.0.1-beta.5, `github.com/sjrusso8/spark-connect-rs`).
   - Only `spark-connect-proto` is free.
   
   `release.yml` claims to publish the Rust crates to crates.io, but `cargo 
publish` will fail outright until the ASF obtains ownership of those two names. 
This needs either a name-transfer conversation with both owners or a different 
naming scheme (e.g. an `apache-*`-prefixed set) — worth settling before the 
restructure lands, since the crate names are baked into every `Cargo.toml`, doc 
example, and README snippet here.
   
   Separately, the existing **`spark-connect-rs` (0.0.2) gets no deprecation 
path**. Current users aren't broken — no new crate reuses that name, and 
`0.0.2` → `4.2.0` won't be picked up by `cargo update` — but they'll also never 
learn the project has been restructured under different names. A final `0.0.3` 
marked deprecated and pointing at the successor crate would close that out.
   
   Minor, but related: the project now answers to five different names 
(`spark-connect-rust` the repo, `spark-connect` the crate, `spark-connect-rs` 
the old crate, `pyspark-client-rust` on PyPI, `pyspark` as the import). The 
three that differ by a few characters are a lasting source of confusion.
   
   ## Silent-wrong-result bugs in the plan builder 
(`crates/spark-connect/src/plan.rs`)
   
   These all follow the same shape — an argument is accepted by the API, then 
dropped before it reaches the proto. No error surfaces; the query just returns 
the wrong answer:
   
   - **`dropna(how=...)` ignores `how` entirely.** `dataframe.rs:742` computes 
`how_str` and stores it on `LogicalPlan::NADrop`, but `plan.rs:667` 
destructures it as `how: _` and forwards only `thresh` → `min_non_nulls`. 
PySpark translates `how` into `min_non_nulls` client-side (`"all"` → 1, `"any"` 
→ column count); that translation is absent, so `dropna(Some("all"), None, 
None)` and `dropna(Some("any"), None, None)` build byte-identical plans and 
both behave as `"any"`.
   - **`hint(name, parameters)` drops all parameters.** `plan.rs:619` 
destructures `parameters: _` and never populates `hint.parameters`, though the 
field exists (`relations.proto:969`). `df.hint("REPARTITION", [10])` sends a 
parameterless hint.
   - **`replace()` silently no-ops on non-numeric values.** `plan.rs:691,699` 
only set `old_value`/`new_value` when the string parses as `f64`; otherwise the 
`Replacement` is pushed with both fields `None`. `df.replace([("foo","bar")])` 
on a string column does nothing at all, with no error.
   - **Explicit-value `pivot` is unimplemented.** `plan.rs:394` sets 
`pivot.col` but never serializes `pivot_values` (comment: "In the future, if 
pivot_values are passed…"), and `group.rs:48` hardcodes `pivot_values: vec![]` 
with no API to supply them.
   - **`fillna` accepts only `i64`** (`dataframe.rs:723`), so a fractional fill 
into a double column is not expressible. Lower severity — an API gap rather 
than a wrong plan — but it undercuts the parity claim.
   
   **These affect every real consumer, not just Rust users.** 
`python/pyspark/sql/dataframe.py` is 28 lines whose substance is `from 
pyspark._pyspark import DataFrame` — it re-exports the Rust `PyDataFrame` 
directly. So a user who `pip install`s the published `pyspark-client-rust` and 
calls `df.dropna(how="all")` goes through `pyspark-rs` → `spark-connect` → the 
same `plan.rs` code above. Both the Rust-native path and the shipped Python 
wrapper hit these bugs. For something positioned as a drop-in `pyspark` 
replacement, "same API call, quietly different answer" is the worst available 
failure mode.
   
   ## The official test harness structurally cannot catch the above
   
   The parity harness is presented as the main quality argument, but it 
exercises a different plan builder than the one that ships.
   
   `scripts/rust_transport_plugin.py` monkeypatches the upstream 
`SparkConnectClient` so its gRPC stub becomes `RustConnectStub`. In that path, 
**upstream pyspark builds the plan** — `_RustStub.ExecutePlan(request)` 
receives an upstream protobuf object and calls `request.SerializeToString()`, 
and the bytes go out through `execute_plan_raw` + `BytesCodec` untouched. 
Rust's job there is transport and Arrow decoding only; 
`crates/spark-connect/src/plan.rs` is never executed.
   
   So the three paths diverge exactly where it matters:
   
   | Path | Who builds the plan | Exercises `plan.rs`? |
   |---|---|---|
   | Rust-native (`examples`) | Rust `spark-connect` | yes |
   | Published wheel (`python/pyspark/`) | Rust, via `_pyspark` | yes |
   | Official suite (`scripts/rust_transport_plugin.py`) | upstream pyspark | 
**no** |
   
   The set of code paths users run and the set the official suite covers don't 
intersect on plan building. A green parity gate therefore says nothing about 
`dropna`/`hint`/`replace`/`pivot` — which is precisely why those four reached 
this PR unnoticed. The golden tests (`tests/golden/*.jsonl`) are the only thing 
covering the Rust plan builder, and none of the four are in their scope. Given 
`plan.rs` is 1366 lines and `functions.rs` is 3351, the open question isn't 
these four bugs — it's how many more of the same shape exist with no test that 
could detect them.
   
   `scripts/audit_no_stubs.sh` has the same false-confidence problem: it's run 
in CI to enforce "no deferrals," but its regex misses the `plan.rs:394` pivot 
deferral ("In the future…") and deliberately excludes bare 
"placeholder"/"stub". A passing audit isn't evidence of completeness; the pivot 
gap is the proof.
   
   ## Retries and reattach are unwired in the Rust core
   
   The description lists the core's responsibilities as "transport: channel, 
retries, reattach," and `reattach.rs` opens with "Implements the full reattach 
protocol." In practice neither is connected on the Rust side:
   
   - `RetryPolicy` / `RetryPolicyState` (`retries.rs`) are only `pub use`d. No 
client method or DataFrame path calls `can_retry`/`next_attempt`; every RPC in 
`client.rs` hits the stub directly with no retry.
   - `ExecutePlanResponseReattachableIterator` (`reattach.rs`) is a data holder 
with getters/setters and no stream-consumption loop. Nothing drives it. Real 
reattach happens only because the *Python* client drives the raw passthrough.
   
   Worth noting this is a regression against what's being replaced: the 
outgoing `spark-connect-rs` did drive reattach for Rust callers — 
`client/mod.rs` has the `while let Some(_) = stream.message().await` loop that 
tracks `response_id`, watches for `ResultComplete`, and calls 
`reattach_execute()` on interruption. Under this PR, `DataFrame::collect()` 
(`dataframe.rs:347`) iterates the raw stream with no retry and no reattach, so 
a transient `UNAVAILABLE` or `INVALID_CURSOR.DISCONNECTED` aborts a Rust-native 
query that both the old crate and the Python path would have resumed. Either 
wire these modules into the Rust client path, or drop the "retries/reattach" 
framing for the core and delete the dead iterator so it stops implying coverage 
that isn't there.
   
   ## PyO3: the GIL is held across blocking RPCs
   
   The streaming path gets this right — `ResponseStream::__next__` wraps the 
blocking read in `py.detach()` and races it against cancellation 
(`transport.rs:114`). The unary and stream-opening calls don't: `execute_plan`, 
`reattach_execute`, `analyze_plan`, `config`, `interrupt`, `release_execute`, 
and `fetch_error_details` all call `block_on(...)` while holding the GIL, as do 
the `spark-connect` crate's `collect`/`count`/`first`/`show`. Each is a 
synchronous network round-trip, so in a multi-threaded Python program every 
other thread stalls for its duration; `SessionBuilder.get_or_create` blocks the 
whole interpreter through the connect handshake. Wrapping these the way 
`__next__` already does would fix it.
   
   Smaller item in the same area: `value_to_py` (`pyspark-rs/src/row.rs:24-37`) 
`.unwrap()`s every `into_pyobject`. These are near-infallible, but a panic 
there unwinds across the FFI boundary instead of raising a Python exception — 
`?` would be safer.
   
   ## Scope and governance
   
   This deletes the entire existing `spark-connect-rs` crate (~19k lines) and 
replaces the project's architecture and identity wholesale, so a couple of 
non-code decisions ride along that I think deserve their own discussion on dev@ 
rather than being carried by a restructure PR:
   
   - **`.asf.yaml` turns GitHub Issues off** (`issues: true` → `false`), 
closing an existing intake channel and redirecting reporters to JIRA.
   - The crates and wheel are versioned **4.2.0** to track the Spark release, 
while the README says **"Status: alpha, work in progress"** and treats API 
parity as a goal. Those two signals point in opposite directions for anyone 
deciding whether to depend on this; the outgoing crate's honest `0.0.2` had the 
opposite problem but at least matched its maturity.
   
   On the architecture itself I want to be clear that I think the layering is a 
genuine improvement — splitting proto / transport / DataFrame / bindings across 
crate boundaries is a real upgrade over the old single crate's module split, 
and the strangler-fig transport seam is a smart way to get the official suite 
involved. My blocking concerns are the crates.io naming (nothing can be 
published as-is) and the four plan-builder bugs, since those ship wrong answers 
to every consumer while the headline test suite stays green.
   
   Happy to file JIRAs for the individual bugs if that's the preferred tracking 
path.
   


-- 
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.

To unsubscribe, e-mail: [email protected]

For queries about this service, please contact Infrastructure at:
[email protected]


---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]

Reply via email to