[SPARK-58752][CONNECT] Deliver session environment variables to Python UDF workers - #58264
Open
RamonZhou wants to merge 5 commits into
Open
[SPARK-58752][CONNECT] Deliver session environment variables to Python UDF workers#58264RamonZhou wants to merge 5 commits into
RamonZhou wants to merge 5 commits into
Conversation
…n UDF workers Python UDFs on Spark Connect run in worker processes whose environment is always empty: the Connect planner builds every Python function with no environment variables, so code reading `os.environ` behaves differently inside a UDF than outside it. This lets a session carry an environment for its Python workers through session configurations under a reserved prefix, one configuration per variable. `spark.pythonWorkerEnv.FOO=bar` makes `FOO` visible as `bar` in `os.environ` inside a Python UDF. The configurations are the authoritative session state; nothing is cached beside them. The environment therefore follows the session wherever ordinary session configurations follow it -- across reattach and retry, and into a session created by `cloneSession` -- while `newSession` starts without one. Validation happens on read rather than where the configuration is set, because an ordinary configuration write has no interception point on this path. A rejection fails the query that would have installed the environment, rather than silently running a Python function without the variables it expects. Messages may name a variable but never carry its value, and a long name is truncated, so a rejection cannot copy a credential into a log or a stack trace. The plan cache is keyed on the environment as well as the relation. A cached plan holds the environment it was built with, baked into every Python function it contains, so an entry may only be reused by a request whose environment matches. The cache key is read without validation, so one invalid entry fails the queries that would install it rather than every query in the session. Only `transformPythonFunction` is wired here, which covers scalar, pandas and Arrow UDFs and the operations sharing that path. Python UDTFs and Python data sources have their own construction sites and keep receiving an empty environment. Co-authored-by: Isaac
Comment rewrapping and expression re-breaking only, no behavior change. scalafmt wraps at 98 columns while scalastyle allows 100, so several comment lines were within scalastyle but over scalafmt's width. Co-authored-by: Isaac
…per request, reject NUL Follow-up on the review of the Python worker environment delivery. Bound what a session can make the server hold. The plan cache keyed on the environment itself, which is unbounded until validated, so a session could multiply the memory it holds by the cache size just by issuing ordinary cacheable queries. The key now holds a SHA-256 fingerprint instead, a fixed size whatever the environment holds. Lengths are folded into the digest so that shifting a boundary between a name and a value cannot collide. Read the environment once per request. The planner took its own snapshot while the cache key was computed from the live configurations, so a concurrent configuration write between a lookup and an insertion could store a plan built with one environment under the key of another, and a later request would reuse it. The snapshot is now taken once and used for the lookup, for building the Python functions, and for the insertion; `usePlanCache` takes the fingerprint from its caller rather than reading the configurations again. Reject a value containing NUL. A process environment cannot carry it, and the JDK's own rejection embeds the offending value in its message, so leaving it to the worker launch would copy a value into a log. Escape control characters in a rejected name. Truncation is not sanitization: a name comes from a configuration key, so it could carry newlines and terminal escape sequences into a message and forge log lines. Guard the limit configurations against negative values, and document zero as accepting no user-provided environment. Drop the handling of a null value. `SQLConf.setConfString` requires a non-null value, so the state was unreachable and the comment described something that cannot happen. Correct two comments. Validation happens when a Python function is built to cover every configuration write surface at once, not because no interception point exists; and case sensitivity is Spark preserving the name, not a promise about every operating system. The accepted name pattern is a portability policy rather than a description of what a process environment can hold. Tests: an end-to-end suite that runs a real Connect client and a real Python worker, planner coverage across scalar eval types and mapInPandas/mapInArrow, the limits at non-default values, message sanitization, and the fingerprint. Co-authored-by: Isaac
The suite failed at import: the UDF was created by a module-level decorator, and constructing one needs a live session, so collection raised SESSION_OR_CONTEXT_NOT_EXISTS before any test ran. The UDF is now built inside the helper that uses it. It also extended SparkConnectSQLTestCase, a mixed fixture whose `spark` is a classic session, so `udf` would have produced a classic function that cannot run against a Connect DataFrame. It now extends ReusedConnectTestCase, whose `spark` is the Connect session, which is what the existing Connect UDF suites use. The two failure-path assertions now match on the message text rather than the error condition name, which a client is not required to surface in the string form of an exception. Co-authored-by: Isaac
…lanner contract Carry the request's environment snapshot in the plan cache key directly rather than a digest of it, as decided in review. The one-snapshot-per-request behaviour is unchanged: the planner still takes a single snapshot and passes it to the cache for both lookup and insertion, so a concurrent configuration write cannot cause a plan built with one environment to be stored under another. Make the request-scoped lifetime of SparkConnectPlanner an explicit class contract. The environment snapshot is derived once per instance, which is only correct because an instance serves one request; the type did not say so, and it is a DeveloperApi with callers beyond the main execute and analyze paths. Correct the class comment. Saying nothing is cached outside the configurations was inaccurate, since a plan cache key holds a request's snapshot. Use try/finally in the end-to-end unset test, so a failed assertion cannot leave a configuration behind in the shared session for a later test to trip on. Tests: an entry is keyed on the snapshot the plan was built with even when the configurations change midway through planning, and the plan built under the old environment is not reused afterwards; successive environments get their own entries; an oversized environment still does not stop an ordinary query being planned and cached; and the Connect cloneSession path is covered alongside SparkSession.cloneSession. Co-authored-by: Isaac
RamonZhou
marked this pull request as ready for review
August 25, 2026 07:28
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?
Python UDFs on Spark Connect run in worker processes whose environment is always empty: the Connect
planner builds every Python function with no environment variables. This lets a session carry an
environment for its Python workers through session configurations under a reserved prefix, one
configuration per variable.
Changes, all confined to
sql/connect/serverapart from four new error sub-conditions:PythonWorkerEnvironment(new): reads the environment from the session's configurations,validates it, and hands out a fresh mutable copy for a single Python function.
SparkConnectPlanner.transformPythonFunctionpopulatesSimplePythonFunction.envVarsfrom it.Every Python function family built at that site therefore receives the environment. Covered by
tests: scalar Python UDFs, Arrow-batched UDFs, scalar pandas UDFs and their iterator variant,
mapInPandasandmapInArrow. Reaching the same site by construction but not separately testedhere: grouped-map, cogrouped-map, stateful pandas functions, streaming
foreach/foreachBatchcallbacks, and Python listeners.at most 512 characters, and 128 KiB total measured as the sum of the UTF-8 lengths of every name
and value. Zero accepts no user-provided environment at all; a negative value is rejected.
Design notes a reviewer may want:
session state, so the environment follows the session wherever ordinary session configurations
follow it: it survives reattach and retry with no code, and
SQLConf.clone()carries it into asession created by
cloneSession, whilenewSessioncorrectly starts without one. There is atest for each.
cache lookup, for building its Python functions, and for the cache insertion. Re-reading would
race with a concurrent configuration write and could store a plan built with one environment
under the key of another.
Python function it contains, so an entry is only reusable by a request carrying the same
environment. Note the key is therefore as large as the environment, and a configuration is stored
before anything validates it, so the limits below bound what reaches a worker rather than what
the session and its cache hold.
BasePythonRunnertakes the map by referenceand writes its own entries into it before launching a worker, so a shared map would leak entries
between functions and an immutable one would fail the assignment.
configuration is covered by one check: the Connect config RPC, SQL
SET, and theapplication-level configurations merged into a new session all arrive there. The cost is that an
invalid environment stays in the session until the user corrects it; the benefit is that it
cannot reach a worker, and that one invalid entry fails only the queries that would install it
rather than every query in the session.
rejection embeds the offending value in its message, so this has to be caught before a worker
launch is attempted.
FOOandfooaretherefore distinct; Windows process environments are case-insensitive, so what a worker observes
there is the platform's business.
except
=and NUL, and container platforms accept their own broader sets, but a name outside[A-Za-z_][A-Za-z0-9_]*cannot be referenced portably from a shell. It is a portability policy,not a description of what a process environment can hold.
that is searched for would accept a name with a trailing newline, since
$also matches before aterminating line break.
INVALID_SPARK_CONFIGcondition rather than adding a new top-levelone. A message may name a variable but never carries its value, and a name is truncated and has
its control characters escaped, so a rejection cannot forge log lines. Note the name itself is
user-chosen, so it is only as safe as what the user put in it.
FOO=in a shell). A null value needs no handling:SQLConfrejects one on the way in, so a config request with an absent value fails rather than storing
null.
Python UDTFs (
transformPythonTableFunction) and Python data sources (transformPythonDataSource)have their own construction sites and keep receiving an empty environment; they are follow-ups.
Not addressed here, and worth a reviewer's attention: a user can set a name that Spark's own worker
protocol uses. Variables the runner sets unconditionally (
SPARK_AUTH_SOCKET_TIMEOUT,SPARK_BUFFER_SIZE,PYTHONPATH,PYTHON_WORKER_FACTORY_SECRET, ...) always overwrite a uservalue, but ones set only under a condition —
SPARK_REUSE_WORKER,SPARK_PIPELINED_UDF,SPARK_HIDE_TRACEBACKand others — are not removed when that condition is false, so a user valuesurvives. Deciding between rejecting Spark-owned names and explicitly clearing every one of them is
left to a follow-up.
Why are the changes needed?
Code that reads
os.environbehaves differently inside a Python UDF than outside it, and a SparkConnect client has no way to influence it. A user can set a value, read it successfully from driver
code, and get a
KeyErrorfor the same name inside a UDF.This is also the gap that blocks moving existing workloads onto Spark Connect: on classic compute an
executor environment can be configured for the application through
spark.executorEnv.*, but thatis application-scoped and set before the context starts, so it has no session-scoped equivalent a
Connect client can use.
Does this PR introduce any user-facing change?
Yes. Session configurations under
spark.pythonWorkerEnv.are now read and installed in theenvironment of the Python worker processes that run the session's Python functions, so
os.environinside a Python UDF can see them. Previously these configurations had no effect, and the worker
environment was always empty.
Setting a malformed or oversized environment now fails the queries that would install it, with
INVALID_SPARK_CONFIG.INVALID_PYTHON_WORKER_ENV_VAR_NAME,INVALID_SPARK_CONFIG.INVALID_PYTHON_WORKER_ENV_VAR_VALUE,INVALID_SPARK_CONFIG.PYTHON_WORKER_ENV_TOO_MANY_VARIABLES, orINVALID_SPARK_CONFIG.PYTHON_WORKER_ENV_TOO_LARGE.The three new bounding configurations are internal.
How was this patch tested?
PythonWorkerEnvironmentSuite(new, 36 tests):case sensitivity, no configurations at all, and that a null value cannot be installed.
length limit with the name bounded in the message, a value containing NUL, more variables than
the limit, exactly the limit, a total size over the limit, and a total size that exceeds the
limit only when counted in UTF-8 bytes rather than characters.
message carries no control characters.
negative value is rejected by the configuration itself.
configurations midway through planning does not mis-key the entry, and the plan built under the
old environment is not reused afterwards; five successive environments produce five entries; an
oversized environment still does not stop an ordinary query being planned and cached.
cloneSessionpath through the session manager, notonly
SparkSession.cloneSession.mapInPandas/mapInArrowreceive the environment; an emptyone when nothing is set; each function gets an independent mutable copy; an invalid environment
fails planning of a Python function but not of a plan without one.
SparkConnectPythonWorkerEnvTests(new, end-to-end through a real Connect client and a real Pythonworker): a UDF reads the value from
os.environ; an unset name is not visible; an update is pickedup;
unsetremoves it; an empty value arrives as empty; a platform-owned variable(
PYTHONUNBUFFERED) still wins; an invalid name and a NUL value fail the query without printing thevalue; and
mapInPandassees the environment.SparkConnectSessionHolderSuitewas updated for the new plan cache key and its plan cache testsstill pass.
SparkThrowableSuitepasses with the new error sub-conditions. The end-to-end suite wasrun against a real Connect server and real Python workers: 11 tests, all passing.
connect/scalastyle,connect/Test/scalastyle, and scalafmt (with CI'schangedOnly=false) areclean.
Was this patch authored or co-authored using generative AI tooling?
Generated-by: Claude Code (Claude Opus 5)