FazBrowse GitHub Viewer | Trending |
URL:
| Home
Tools: [Download Repo ZIP]   [Original HTTPS Page]

[SPARK-58752][CONNECT] Deliver session environment variables to Python UDF workers by RamonZhou · Pull Request #58264 · apache/spark · GitHub

/ spark Public

[SPARK-58752][CONNECT] Deliver session environment variables to Python UDF workers - #58264

Open
RamonZhou wants to merge 10 commits into
apache:masterfrom
RamonZhou:SPARK-58752-udf-env
Open

[SPARK-58752][CONNECT] Deliver session environment variables to Python UDF workers#58264
RamonZhou wants to merge 10 commits into
apache:masterfrom
RamonZhou:SPARK-58752-udf-env

Conversation

RamonZhou commented Aug 25, 2026
edited
Loading

Copy link
Copy Markdown

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.

spark.conf.set("spark.pythonWorkerEnv.MY_SETTING", "abc")

@udf("string")
def f(_):
    import os
    return os.environ["MY_SETTING"]   # "abc"; previously a KeyError

Changes, all confined to sql/connect/server apart 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.transformPythonFunction populates SimplePythonFunction.envVars from 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,
    mapInPandas and mapInArrow. Reaching the same site by construction but not separately tested
    here: grouped-map, cogrouped-map, stateful pandas functions, streaming foreach /
    foreachBatch callbacks, and Python listeners.
  • SparkConnectConfigHandler.handleSet validates a write under the prefix before storing it, so a
    malformed variable set through the config RPC is refused instead of being kept in the session.
    The check and the write it validated happen as one unit, under the monitor that guards the
    session configurations.
  • The prefix is documented under a new Session Configuration heading in the Spark Connect section
    of docs/configuration.md.
  • The session plan cache is keyed on the environment in addition to the relation.
  • Three internal, cluster-level configurations bound the environment: at most 100 variables, names
    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:

  • The configurations are the authoritative session state. No second copy is maintained as
    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 a
    session created by cloneSession, while newSession correctly starts without one. There is a
    test for each.
  • One snapshot per request. A request reads the environment once and uses that snapshot for the
    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.
  • The cache key holds the request's snapshot. A cached plan bakes the environment into every
    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. A write through the config
    RPC is bounded by the limits before it is stored, but one through SQL SET is not, so the limits
    bound what reaches a worker rather than what the session and its cache can be made to hold.
  • A fresh mutable copy per function is required. BasePythonRunner takes the map by reference
    and 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.
  • Validation happens in two places, and both are needed. A write through the Connect config
    RPC is validated before it is stored, against the environment the write would produce, so a
    malformed variable is refused at the call that set it and the session is never left carrying an
    environment its queries cannot use. That covers spark.conf.set and the options
    SparkSession.builder.config applies. It cannot be the only check: SQL SET reaches the session
    configurations through SetCommand, and application-level configurations are merged into a new
    session, neither of which passes through the RPC. So the environment is validated again when a
    Python function is built, which every write path does reach, and that is what guarantees an
    invalid environment cannot reach a worker. The remaining cost of the second layer is that an
    environment installed by one of those paths stays in the session until the user corrects it,
    failing only the queries that would install it rather than every query in the session.
    Covering those paths at write time instead would mean a prefix-validator hook in
    SQLConf.setConfString, which lives in sql/catalyst and cannot see the Connect server.
  • The write-time check and its write are atomic. They are performed under
    SQLConf.settings, the monitor every writer of the session configurations takes:
    setConfString and getAllConfs both hold it, and SQLConf.setConf(props) holds it across a
    compound write for the same reason. Without it two concurrent writers could each validate against
    the environment as it was before the other's write and jointly exceed a limit that neither write
    appeared to break. This closes the window between the check and the write, including against a
    concurrent SQL SET; it does not make SQL SET itself validated.
  • A rejection never carries the value, on any path. The silent path reports a refusal as a
    warning, and that warning outlives the response: the Scala client logs it and the Python client
    raises it. It therefore names only the configuration key. Reads through this handler are already
    redacted through spark.redaction.regex, so a write path that echoed values back would have
    contradicted the handler's own policy.
  • A removal is deliberately not validated. It can only shrink the environment, and it is how a
    session recovers from an environment that one of the unchecked write paths left invalid.
  • A value containing NUL is rejected. A process environment cannot carry it, and the JDK's own
    rejection embeds the offending value in its message, so this has to be caught before a worker
    launch is attempted.
  • Names are preserved case-sensitively. On a case-sensitive operating system FOO and foo are
    therefore distinct; Windows process environments are case-insensitive, so what a worker observes
    there is the platform's business.
  • The accepted name pattern is deliberately stricter than the OS requires. POSIX permits any byte
    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.
  • The name pattern is checked with a whole-string match rather than a search. An anchored pattern
    that is searched for would accept a name with a trailing newline, since $ also matches before a
    terminating line break.
  • Rejections reuse the existing INVALID_SPARK_CONFIG condition rather than adding a new top-level
    one. 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.
  • An empty value is accepted (FOO= in a shell). A null value needs no handling: SQLConf
    rejects 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. Spark wins every contested write — BasePythonRunner takes this map by reference and
overwrites its own keys in it, and PythonWorkerFactory applies the map before setting its own — so
a user value never displaces one that Spark writes, PYTHON_WORKER_FACTORY_SECRET and
PYTHONUNBUFFERED included.

The gap is the variables Spark writes only under a condition. SPARK_REUSE_WORKER,
SPARK_HIDE_TRACEBACK, SPARK_SIMPLIFIED_TRACEBACK, SPARK_TRACEBACK_WITH_LOCALS,
SPARK_PIPELINED_UDF, PYSPARK_EXECUTOR_MEMORY_MB, PYTHON_FAULTHANDLER_DIR,
PYSPARK_SPARK_SESSION_UUID, OMP_NUM_THREADS and the transport variables are not cleared when
their condition is false, so a user value survives and Spark's own code then reads it back as though
Spark had set it: PYTHON_FAULTHANDLER_DIR becomes a File, and PYSPARK_SPARK_SESSION_UUID
selects where worker logs are routed. Spark's intent in those branches is that the variable be
unset, which is currently expressed as not writing it rather than as removing it.

PYTHONPATH is a separate case: it is merged rather than replaced, after Spark's own entries, so a
user can add importable module paths to a worker, though not shadow pyspark's own modules. Clearing
the conditional names when their condition is false, and deciding whether merging PYTHONPATH is
wanted, are left to a follow-up.

Why are the changes needed?

Code that reads os.environ behaves differently inside a Python UDF than outside it, and a Spark
Connect client has no way to influence it. A user can set a value, read it successfully from driver
code, and get a KeyError for 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 that
is 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 the
environment of the Python worker processes that run the session's Python functions, so os.environ
inside 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, 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, or
INVALID_SPARK_CONFIG.PYTHON_WORKER_ENV_TOO_LARGE. Through spark.conf.set it fails at the call
that set it, and nothing is stored. Through SQL SET or an application-level configuration it is
stored and fails the queries that would install it in a worker.

SparkSession.builder.config applies its options silently, so a malformed variable set there is
reported as a warning and not stored rather than failing session creation, which is how the builder
already treats an option it cannot apply.

The warning that a silent configuration request returns for a write it could not apply no longer
repeats the rejected value, for any configuration key, only the key and the reason. The prefix is
also now documented in docs/configuration.md.

The three new bounding configurations are internal.

How was this patch tested?

PythonWorkerEnvironmentSuite (new, 36 tests):

  • Reading: variables under the prefix, configurations outside the prefix ignored, an empty value,
    case sensitivity, no configurations at all, and that a null value cannot be installed.
  • Validation: a malformed name (including a trailing newline and an empty name), a name over the
    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 safety: a name carrying newlines, tabs, DEL and an ANSI escape is escaped, and the
    message carries no control characters.
  • Limits: each of the three is exercised at a non-default value, zero accepts nothing, and a
    negative value is rejected by the configuration itself.
  • Plan cache: an entry is keyed on the snapshot the plan was built with; a change to the
    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.
  • Session lifecycle also covers the Connect cloneSession path through the session manager, not
    only SparkSession.cloneSession.
  • Delivery: every scalar family and mapInPandas / mapInArrow receive the environment; an empty
    one 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.

SparkConnectConfigHandlerSuite (9 tests added): a valid variable is stored; an invalid name and a
value carrying NUL are refused and not stored; a limit on the collection is enforced against the
environment the write would produce, while an already accepted write stays; a silent request reports
a refusal as a warning instead of failing, and that warning carries the key but not the rejected
value; two writers racing at a barrier against a limit of one variable end with exactly one write
accepted and the limit intact; Unset is not validated, so a session can leave an environment
installed through a path the RPC does not see; and a key outside the reserved prefix is not
validated as a variable name.

SparkConnectPythonWorkerEnvTests (new, end-to-end through a real Connect client and a real Python
worker): a UDF reads the value from os.environ; an unset name is not visible; an update is picked
up; unset removes it; an empty value arrives as empty; a platform-owned variable
(PYTHONUNBUFFERED) still wins; an invalid name and a NUL value fail spark.conf.set itself
without printing the value and leave nothing stored; the same invalid name installed through SQL
SET instead is stored and fails the query, which is what exercises the second layer; and
mapInPandas sees the environment.

SparkConnectSessionHolderSuite was updated for the new plan cache key and its plan cache tests
still pass. SparkThrowableSuite passes with the new error sub-conditions. The end-to-end suite was
run against a real Connect server and real Python workers: 11 tests, all passing.

build/sbt "connect/testOnly *PythonWorkerEnvironmentSuite *SparkConnectSessionHolderSuite"
build/sbt "core/testOnly *SparkThrowableSuite"
python/run-tests --testnames pyspark.sql.tests.connect.test_connect_python_worker_env

connect/scalastyle, connect/Test/scalastyle, and scalafmt (with CI's changedOnly=false) are
clean.

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

Generated-by: Claude Code (Claude Opus 5)

…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
RamonZhou changed the title [SPARK-58752][CONNECT] Deliver session environment variables to Python UDF workers [WIP][SPARK-58752][CONNECT] Deliver session environment variables to Python UDF workers Aug 25, 2026
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 changed the title [WIP][SPARK-58752][CONNECT] Deliver session environment variables to Python UDF workers [SPARK-58752][CONNECT] Deliver session environment variables to Python UDF workers Aug 25, 2026
RamonZhou marked this pull request as ready for review August 25, 2026 07:28
RamonZhou marked this pull request as draft August 25, 2026 16:33
RamonZhou marked this pull request as ready for review August 25, 2026 17:17
…n it is set

Reject a malformed variable under spark.pythonWorkerEnv. when the configuration
is written, instead of only when a Python function is built. The feature is not
gated, so a session that stored an invalid entry could not run a Python function
until the user found and corrected it; refusing the write leaves the session
clean and points the failure at the call that caused it.

The check runs against the environment the write would produce, not the written
entry alone, because a limit on the collection cannot be evaluated from one
entry. It sits inside the existing try in handleSet, so a silent request reports
a refusal as a warning exactly as it does for any other rejected write.

This cannot be the only check. It covers the Spark Connect config RPC, which is
how a client sets a configuration and how SparkSession.builder.config applies
one, but SQL SET reaches the session configurations through SetCommand and the
application-level configurations are merged into a new session, so neither
passes through the RPC. Validation when a Python function is built therefore
stays as the check that no invalid environment can reach a worker. Covering the
other paths at write time would need a prefix-validator hook in
SQLConf.setConfString, which lives in sql/catalyst and cannot see the Connect
server.

A removal is deliberately not validated. It can only shrink the environment, and
it is how a session recovers from an environment that one of the unchecked paths
left invalid.

Tests: a valid variable is stored; an invalid name and a value carrying NUL are
refused and not stored; a limit on the collection is enforced against the
environment the write would produce while an already accepted write stays; a
silent request warns instead of failing; Unset is not validated; and a key
outside the reserved prefix is not validated as a variable name. End to end, an
invalid name and a NUL value now fail spark.conf.set itself, and the same
invalid name installed through SQL SET is stored and fails the query, which
exercises the second layer.

Co-authored-by: Isaac

zhengruifeng left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Choose a reason Spam Abuse Off Topic Outdated Duplicate Resolved Low Quality

4 blocking, 1 non-blocking, 1 nit.
The core delivery and cache-snapshot model is coherent, but four behavioral and contract gaps should be fixed before merge.

Design / architecture (3)

  • sql/connect/server/src/main/scala/org/apache/spark/sql/connect/service/PythonWorkerEnvironment.scala:62: Session variables can impersonate conditionally written Spark worker controls. -- see inline
  • sql/connect/server/src/main/scala/org/apache/spark/sql/connect/service/PythonWorkerEnvironment.scala:182: Concurrent config RPCs can jointly exceed limits after both writes succeed. -- see inline
  • sql/connect/server/src/main/scala/org/apache/spark/sql/connect/planner/SparkConnectPlanner.scala:96: A reusable DeveloperApi planner silently pins the first transform's environment. -- see inline

Correctness (1)

  • sql/connect/server/src/main/scala/org/apache/spark/sql/connect/service/SparkConnectConfigHandler.scala:91: Silent validation failures disclose the rejected environment value. -- see inline

Suggestions (1)

  • sql/connect/server/src/main/scala/org/apache/spark/sql/connect/service/PythonWorkerEnvironment.scala:32: The new public configuration prefix is documented only on an internal object. -- see inline

Nits: 1 minor item (see inline comments).

Verification

I traced the complete path from the session configuration through PythonWorkerEnvironment.read, the planner's request snapshot, PlanCacheKey, per-function mutable copies, BasePythonRunner, and PythonWorkerFactory. The cache lookup and insertion use the same immutable snapshot, and unconditional Spark-owned variables overwrite session values. I also traced the failure path through SparkConnectConfigHandler: SQLConf snapshots and writes synchronize separately, while silent failures interpolate the raw value and are emitted by the Python client or logged by the JVM client.

* An instance is request-scoped: construct one per request and discard it. Some state is derived
* once and reused for the whole request -- notably the Python worker environment, which must be a
* single snapshot so that a plan cannot be built with one environment and cached under another.
* Reusing an instance across requests would pin that state to whatever the first request

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Choose a reason Spam Abuse Off Topic Outdated Duplicate Resolved Low Quality

Capture the environment per top-level transform instead of making this reusable DeveloperApi object one-shot by convention. SparkConnectPlanner remains publicly constructible and transformRelation can be called repeatedly, so a caller that updates the session environment between transforms silently reuses the first snapshot and cache key. Please thread a request snapshot through recursive translation, or structurally enforce a one-request planner lifetime.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Choose a reason Spam Abuse Off Topic Outdated Duplicate Resolved Low Quality

Planner are constructed per-request in tree, so this issue will not happen; it only happens when the user constructs a Planner by themself, calls transformRelation multiple times, and mutates the env vars in between.

The first suggestion will break correctness (capture the environment per top-level transform) may cause a plan being built under a set of env vars but being cached under another set of env vars. This is a bigger issue in my opinion.

The second suggestion may work but requires a lot of refactoring and potentially changing APIs.

I prefer document this as an accepted risk (as mentioned in the comments here), env vars will be captured only once per instance, and will be used for the whole request/plan.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Choose a reason Spam Abuse Off Topic Outdated Duplicate Resolved Low Quality

In fact, none of the configs are included in the plan cache key nowadays. So if a spark.conf field is changed and the same request was run again, it will use the stale config values from a cache hit.

So for env vars, we already treat them as a config value. Maybe we can adopt the behavior and exclude them in the plan cache key? The env vars probably won't change frequently, except maybe for rotating secrets.

…omic write, docs

Leave the rejected value out of the warning a silent configuration request
returns. A configuration value can be a secret, and this warning outlives the
response: the Scala client logs it and the Python client raises it. The warning
now names the key and the reason only. Reads through this handler are already
redacted through spark.redaction.regex, so a write path that echoed a value back
contradicted the handler's own policy. This applies to every configuration key,
not only to the Python worker environment prefix.

Perform the environment check and the write it validated as one unit, under
SQLConf.settings, the monitor that every writer of the session configurations
takes. Without it two concurrent writers could each validate against the
environment as it stood before the other's write, and jointly exceed a limit that
neither write appeared to break. SQLConf holds this same monitor across its own
compound write in setConf(props), and getAllConfs takes it to snapshot. This
closes the window between the check and the write, including against a concurrent
SQL SET; it does not make SQL SET itself validated, which is why validation when
a Python function is built remains the authoritative check.

Document spark.pythonWorkerEnv.<NAME> under a new Session Configuration heading
in the Spark Connect section of the configuration documentation, covering the
session scope, how it differs from application-scoped spark.executorEnv.*, the
validation rules, the two failure timings, that a variable Spark sets for a
worker itself wins, and that values are not redacted from the worker environment.

Fix a sentence fragment in the PlanCacheKey parameter documentation.

Tests: a silent rejection carries the key but not the rejected value; and two
writers racing at a barrier against a limit of one variable end with exactly one
write accepted and the limit intact.

Co-authored-by: Isaac

HyukjinKwon left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Choose a reason Spam Abuse Off Topic Outdated Duplicate Resolved Low Quality

0 blocking, 1 non-blocking, 0 nits.
A clean, well-tested feature; the only open point is a documented, author-declined env-var collision already tracked in an existing inline thread.

Already raised in existing discussion (1)

  • User env names can impersonate conditionally-set Spark worker controls; validate() polices only name shape, not Spark-reserved names (author-declined, tracked inline). -- existing discussion

Verification

Traced the config write path (SparkConnectConfigHandler.handleSet -> PythonWorkerEnvironment.validateConfigChange -> SQLConf.settings) and confirmed the check and write share one reentrant monitor with getAllConfs/setConfString; traced the delivery path (transformPythonFunction -> toMutableJavaMap -> SimplePythonFunction.envVars) and the plan-cache key (usePlanCache -> PlanCacheKey(rel, env)); and confirmed the since-version against apache/spark master (5.0.0-SNAPSHOT) and branch-4.x (4.4.0-SNAPSHOT).

RamonZhou and others added 3 commits August 28, 2026 06:55
…r with SQL Core

Move PythonWorkerEnvironment out of the Spark Connect server and into
sql/core, so that the reserved prefix, the accepted variable names, the NUL
check, the limits and the merge precedence are defined once and can be shared
with a front end other than Connect. Connect keeps its request-level snapshot,
its plan cache key and its write-time validation; only the import and the merge
call change.

Move the three bounds out of the Connect configuration object into
StaticSQLConf, where a shared configuration belongs, so that a caller outside
Connect does not depend on Connect's configuration:

  spark.connect.session.pythonWorkerEnv.maxVariables
    -> spark.sql.pythonWorkerEnv.maxVariables
  spark.connect.session.pythonWorkerEnv.maxNameLength
    -> spark.sql.pythonWorkerEnv.maxNameLength
  spark.connect.session.pythonWorkerEnv.maxTotalSizeBytes
    -> spark.sql.pythonWorkerEnv.maxTotalSizeBytes

The keys deliberately keep a namespace of their own rather than sitting under
spark.pythonWorkerEnv.: SQLConf.mergeSparkConf copies every SparkConf entry
into a new session's SQLConf, so a limit named under the reserved prefix would
be read back as an environment variable named maxVariables and installed in
every Python worker. A test pins that the limit keys are not under the prefix.
They stay static configurations, so a session cannot raise its own bounds.

Replace toMutableJavaMap with merge and mergeToJavaMap, which take the
environment a function already carries and apply the session's over it. The
session wins a conflict: a name in the original comes from a broader scope --
for a classic session, the application-wide spark.executorEnv.* -- and the
session's own configuration is the more specific statement of intent. It also
matches what a worker observes anyway, since PythonWorkerFactory starts from
the executor process environment and applies this map over it. merge throws
rather than returning a function it cannot rewrite, because dropping the
environment silently would let a UDF run without a variable it was told to
have.

Tests: a new sql/core suite covers reading and validating, the precedence rule
against an environment built through SparkConf.getExecutorEnv rather than a
hand-written map, that a merge does not mutate its input, that each merge
yields an independent mutable map, and that an unrewritable function fails
loudly.

Co-authored-by: Isaac
…l sites

The previous commit landed only the file move and the new SQL Core suite: the
six modified files were not staged, so it does not build on its own. This adds
them.

  StaticSQLConf            the three bounds, as spark.sql.pythonWorkerEnv.max*
  config/Connect.scala     the Connect-named bounds removed
  SparkConnectPlanner      import from sql/core; build through mergeToJavaMap
  SparkConnectConfigHandler import from sql/core
  the two Connect suites   the moved configuration entries and merge helper

With this commit the branch is the state that was verified: 181 Scala tests
across catalyst, SQL Core and Connect, and the end-to-end PySpark suite against
a rebuilt assembly.

Co-authored-by: Isaac
…ironment limits

`SparkConfigBindingPolicySuite` requires every registered `ConfigEntry` to
declare a `ConfigBindingPolicy`. The three Python worker environment limits did
not, so the suite failed once they moved into `StaticSQLConf`.

The limits were never compliant with that gate. The audit walks
`ConfigEntry.listAllEntries()`, which only sees entries whose enclosing object
has been initialized, and the hive test JVM never loads the Connect config
object that previously held them. Moving them into `StaticSQLConf`, which is
initialized there, made them visible.

They are `NOT_APPLICABLE`: a static limit cannot differ between the session that
created a view or a UDF and one that calls it, and it never changes what a body
resolves to, only whether a write to the environment is accepted.

Co-authored-by: Isaac <no-reply@databricks.com>
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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants


Back | FazBrowse Home | New Git URL