| FazBrowse GitHub Viewer | Trending | | Home |
| Tools: [Download Repo ZIP] [Original HTTPS Page] |
Sorry, something went wrong.
…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
…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
There was a problem hiding this comment.
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.
Nits: 1 minor item (see inline comments).
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.
Sorry, something went wrong.
| * 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 |
There was a problem hiding this comment.
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.
Sorry, something went wrong.
There was a problem hiding this comment.
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.
Sorry, something went wrong.
There was a problem hiding this comment.
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.
Sorry, something went wrong.
…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
There was a problem hiding this comment.
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.
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).
Sorry, something went wrong.
…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>
| Back | FazBrowse Home | New Git URL |
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 KeyErrorChanges, all confined to sql/connect/server apart from four new error sub-conditions:
validates it, and hands out a fresh mutable copy for a single Python function.
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.
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.
of docs/configuration.md.
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 a
session created by cloneSession, while newSession correctly starts without one. There is a
test 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. 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.
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.
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.
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.
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.
session recovers from an environment that one of the unchecked write paths left invalid.
rejection embeds the offending value in its message, so this has to be caught before a worker
launch is attempted.
therefore 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 a
terminating line break.
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.
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):
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.
only SparkSession.cloneSession.
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.
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)