spark-test commented on code in PR #59778:
URL: https://github.com/apache/spark/pull/59778#discussion_r4429903621
##########
sql/connect/server/src/main/scala/org/apache/spark/sql/connect/planner/SparkConnectPlanner.scala:
##########
@@ -2346,12 +2346,68 @@ class SparkConnectPlanner(
name = fun.getFunctionName,
func = function,
dataType = transformDataType(udf.getOutputType),
- pythonEvalType = udf.getEvalType,
+ pythonEvalType = correctPythonEvalType(udf.getEvalType, function),
udfDeterministic = fun.getDeterministic,
// Set only for incremental Python aggregators (see PythonAggregate).
bufferType = if (udf.hasBufferType) transformDataType(udf.getBufferType)
else null)
}
+ /**
+ * Falls back to a regular Python UDF when the Python that runs an
Arrow-optimized one cannot
+ * run it, as a Python client does before it creates the UDF, see
`_create_py_udf` in
+ * `pyspark.sql.udf`. A Spark Connect client checks its own Python rather
than the one that runs
+ * the UDF, so the server checks again. It checks once per session for each
Python executable,
+ * environment and pandas requirement, and keeps the UDF Arrow-optimized if
the check cannot
+ * run.
+ */
+ private def correctPythonEvalType(evalType: Int, function:
SimplePythonFunction): Int = {
+ if (evalType == PythonEvalType.SQL_ARROW_BATCHED_UDF) {
Review Comment:
**Non-blocking (P2):** Only `SQL_ARROW_BATCHED_UDF` is corrected here, but
Python UDTFs reach the server the same way.
`spark.sql.execution.pythonUDTF.arrow.enabled` defaults to true, both
`_create_py_udtf` implementations choose `SQL_ARROW_TABLE_UDF` after checking
only the client's own pandas and PyArrow, and
`createPythonUserDefinedTableFunction` passes `udtf.getEvalType` through for
inline and registered UDTFs. On the PR's own reproduction server
(`apache/spark:4.2.0-scala2.13-java21-python3-ubuntu`), a default `@udtf`
therefore still fails with `[PACKAGE_NOT_INSTALLED] PyArrow`, while a default
`@udf` on that server now falls back.
The same fallback applies to UDTFs: `SQL_ARROW_TABLE_UDF` to
`SQL_TABLE_UDF`, requiring pandas only under
`spark.sql.legacy.execution.pythonUDTF.pandas.conversion.enabled`, decided at
the same point as the UDF fallback. The description defers UDTFs to a
follow-up; unless that exemption is accepted, the two Arrow-by-default function
kinds behave differently on the same server.
**Recommended change:** Apply the execution-time fallback to Arrow-optimized
Python UDTFs in Spark Connect sessions. When the capability check on the
executor reports that its worker Python cannot import PyArrow (or pandas, with
the UDTF legacy pandas conversion on), evaluate the partition with the
row-based UDTF evaluation and log a warning; otherwise keep the Arrow path. Add
Spark Connect UDTF tests with PyArrow and pandas hidden from the workers'
Python, and mention UDTFs, including that their return values then convert as a
regular Python UDTF's, in the migration guide note.
**Why this works:** UDTFs then fall back under the same rule as UDFs,
decided by the Python that runs them, so a default @udtf on a server without
PyArrow returns rows instead of failing, and servers whose workers have PyArrow
keep the Arrow path.
**Scope:** Extends the execution-time Arrow fallback from Python UDFs to
Python UDTFs in Spark Connect sessions.
**Risks:** Partitions of one UDTF can use different conversions when
executors' Python installations differ. Explain output keeps showing the Arrow
UDTF operator when partitions fall back. A UDTF partition that falls back
converts return values as regular Python UDTFs do, which differs from the Arrow
path for some values, as with the client-side fallback.
**Constraints:** The row-based fallback must produce the same output
attributes as the Arrow UDTF evaluation. Pandas is required only with
spark.sql.legacy.execution.pythonUDTF.pandas.conversion.enabled.
**Exclusions:**
- UDTF analyze calls, which run on the driver and do not use the Arrow path.
**Success:** On a Spark Connect server whose workers' Python lacks PyArrow,
a default @udtf returns the rows the same UDTF returns as a regular Python
UDTF, through the row-based path, and a warning is logged. On a server whose
workers' Python has PyArrow, Arrow-optimized UDTFs run as on the base branch.
**Verification:**
- **Behavior:** With PyArrow hidden from the workers' Python, a default
@udtf in a Spark Connect session, both inline and registered, returns the rows
the same UDTF returns as a regular Python UDTF. With pandas hidden and the UDTF
legacy pandas conversion on, it falls back as well, and with that conversion
off it stays on the Arrow path.
- **Compatibility:** With the internal fallback conf off, as for classic
sessions, Arrow-optimized UDTF evaluation is unchanged.
##########
sql/connect/server/src/main/scala/org/apache/spark/sql/connect/planner/SparkConnectPlanner.scala:
##########
@@ -2346,12 +2346,68 @@ class SparkConnectPlanner(
name = fun.getFunctionName,
func = function,
dataType = transformDataType(udf.getOutputType),
- pythonEvalType = udf.getEvalType,
+ pythonEvalType = correctPythonEvalType(udf.getEvalType, function),
udfDeterministic = fun.getDeterministic,
// Set only for incremental Python aggregators (see PythonAggregate).
bufferType = if (udf.hasBufferType) transformDataType(udf.getBufferType)
else null)
}
+ /**
+ * Falls back to a regular Python UDF when the Python that runs an
Arrow-optimized one cannot
+ * run it, as a Python client does before it creates the UDF, see
`_create_py_udf` in
+ * `pyspark.sql.udf`. A Spark Connect client checks its own Python rather
than the one that runs
+ * the UDF, so the server checks again. It checks once per session for each
Python executable,
+ * environment and pandas requirement, and keeps the UDF Arrow-optimized if
the check cannot
+ * run.
+ */
+ private def correctPythonEvalType(evalType: Int, function:
SimplePythonFunction): Int = {
+ if (evalType == PythonEvalType.SQL_ARROW_BATCHED_UDF) {
+ val conf = session.sessionState.conf
+ // The executable and environment that the Python UDF runners launch
workers with.
+ val pythonExec =
conf.pysparkWorkerPythonExecutable.getOrElse(function.pythonExec)
+ // Arrow-optimized Python UDFs need pandas only with the legacy pandas
conversion.
+ val requirePandas = conf.legacyPandasConversionUDF
+ // An invalid environment fails the worker launch with the reason, so it
is left to that.
+ val canRunArrow =
Try(PythonWorkerEnvironment.mergeValidated(function.envVars, conf))
+ .map(_.asScala.toMap)
+ .toOption
+ .forall { envVars =>
+ sessionHolder.arrowOptimizationChecks.computeIfAbsent(
Review Comment:
**Nit (P3):** `computeIfAbsent` holds the bin's lock while this mapping
function runs, and the function starts the worker Python and waits for it: up
to `PROCESS_WAIT_TIMEOUT_MS` (10 s) in `waitFor`, then up to another 10 s in
`reader.join`. `ConcurrentHashMap` documents that other updates may block while
the mapping function runs and asks for a short computation. Operations of one
Connect session can plan concurrently, on ExecutePlan runner threads and on
AnalyzePlan calls such as `df.schema`. A second operation that plans a UDF with
the same key, or with a key that lands in the same bin, blocks entering the
monitor, and `interruptOperation` or `interruptTag` cannot cancel it, because a
thread waiting for a monitor ignores `Thread.interrupt()`. It waits about 0.5 s
typically, and about 10 s when the import hangs until the check times out.
See **Shared repair plan 1** in the review body.
##########
sql/connect/server/src/main/scala/org/apache/spark/sql/connect/planner/SparkConnectPlanner.scala:
##########
@@ -2346,12 +2346,68 @@ class SparkConnectPlanner(
name = fun.getFunctionName,
func = function,
dataType = transformDataType(udf.getOutputType),
- pythonEvalType = udf.getEvalType,
+ pythonEvalType = correctPythonEvalType(udf.getEvalType, function),
Review Comment:
**Non-blocking (P2):** This fixes the eval type into the UDF when Connect
plans or registers it, from a probe that `correctPythonEvalType` runs on the
driver host with the session settings as of planning. The Python UDF runners
instead resolve `spark.sql.execution.pyspark.python`, `spark.pythonWorkerEnv.*`
and the legacy pandas conf on the executors at each action, and they install
the environment at worker launch "so a cached plan cannot pin an older
environment" (`PythonUDFRunner.scala:44`).
Two cases that work today regress:
- Host: when the driver's Python at that executable path lacks PyArrow but
the executors' Python has it, for example a Connect server started in client
mode from an image without PyArrow whose executor image has it, default UDFs
move from `ArrowEvalPython` to `BatchEvalPython`. That is the slower path, and
return values whose conversion differs change silently: `@udf('boolean')`
returning `1` gives `True` with Arrow and `NULL` without, per the return-type
coercion goldens. The only signal is a server log, and the migration note does
not say that results can change. Classic PySpark makes the same driver-side
choice, but these Connect deployments run the UDFs Arrow-optimized today.
- Time: a function registered, or a DataFrame kept in the session plan
cache, while the worker Python lacked PyArrow keeps `SQL_BATCHED_UDF` after the
session points `spark.pythonWorkerEnv.PYTHONPATH` or
`spark.sql.execution.pyspark.python` at a Python that has it. In the other
direction, one planned while PyArrow was present still fails after the session
switches to a Python without it.
Local mode, where the driver runs the workers' Python, is not affected by
the host case.
See **Shared repair plan 1** in the review body.
##########
core/src/main/scala/org/apache/spark/api/python/PythonUtils.scala:
##########
@@ -196,4 +197,70 @@ private[spark] object PythonUtils extends Logging {
broadcastVars = List.empty.asJava,
accumulator = null)
}
+
+ /**
+ * Checks whether `pythonExec` can run Arrow-optimized Python UDFs, that is,
whether it has
+ * PyArrow at the version PySpark requires, and pandas too if
`requirePandas`, which the legacy
+ * pandas conversion needs. A Python client runs a similar check before it
creates such a UDF,
+ * see `_create_py_udf` in `pyspark.sql.udf`, where it always requires
pandas.
+ *
+ * It runs `pythonExec` with the environment, Python path and working
directory that
+ * `PythonWorkerFactory` gives a Python worker launched with `envVars`.
+ *
+ * @return `None` if it can, and why it cannot otherwise.
+ * @throws SparkException if the check fails otherwise, or does not finish
within `timeoutMs`.
+ */
+ private[spark] def checkArrowOptimization(
+ pythonExec: String,
+ envVars: Map[String, String],
+ requirePandas: Boolean = false,
+ timeoutMs: Long = PythonWorkerFactory.PROCESS_WAIT_TIMEOUT_MS):
Option[String] = {
+ // Exits with 3 only when PyArrow, or pandas when required, is missing or
too old, so that no
+ // other failure, such as PySpark not being importable, is taken for that.
+ val checks = (if (requirePandas) Seq("require_minimum_pandas_version")
else Nil) :+
+ "require_minimum_pyarrow_version"
+ val code =
+ s"""
+ |import sys
+ |from pyspark.sql.pandas.utils import ${checks.mkString(", ")}
+ |try:
+ | ${checks.map(_ + "()").mkString("; ")}
+ |except ImportError as e:
+ | print(e)
+ | sys.exit(3)
+ |""".stripMargin
+ val jobArtifactUUID =
+
JobArtifactSet.getCurrentJobArtifactState.map(_.uuid).getOrElse("default")
+ val workingDir = if (jobArtifactUUID != "default") {
+ val dir = new File(SparkFiles.getRootDirectory(), jobArtifactUUID)
+ dir.mkdir()
Review Comment:
**Nit (P3):** On the driver, `SparkFiles.getRootDirectory()` is
`SparkEnv.driverTmpDir`, which only `SparkEnv.stop` deletes. Closing a Connect
session does not remove `<root>/<session UUID>`: `ArtifactManager` cleanup
removes the file-server entries and the artifact path, and the executor's
session cleanup deletes only under the executor's own root, which is the
driver's root only in local mode. So on a cluster-mode server every session
that plans a default `@udf` now leaves an empty directory behind, where before
only sessions that added files or archives, or ran a Python planning worker
such as a UDTF's `analyze` or a Python data source, did. The count grows with
session churn over the server's lifetime.
See **Shared repair plan 1** in the review body.
##########
core/src/main/scala/org/apache/spark/api/python/PythonUtils.scala:
##########
@@ -196,4 +197,70 @@ private[spark] object PythonUtils extends Logging {
broadcastVars = List.empty.asJava,
accumulator = null)
}
+
+ /**
+ * Checks whether `pythonExec` can run Arrow-optimized Python UDFs, that is,
whether it has
+ * PyArrow at the version PySpark requires, and pandas too if
`requirePandas`, which the legacy
+ * pandas conversion needs. A Python client runs a similar check before it
creates such a UDF,
+ * see `_create_py_udf` in `pyspark.sql.udf`, where it always requires
pandas.
+ *
+ * It runs `pythonExec` with the environment, Python path and working
directory that
+ * `PythonWorkerFactory` gives a Python worker launched with `envVars`.
+ *
+ * @return `None` if it can, and why it cannot otherwise.
+ * @throws SparkException if the check fails otherwise, or does not finish
within `timeoutMs`.
Review Comment:
**Nit (P3):** When `pythonExec` cannot be started (a missing or
non-executable path, or a relative path that does not resolve in the working
directory), `Utils.executeCommand` throws `java.io.IOException` from
`ProcessBuilder.start` before the `try` block, and it propagates unwrapped, as
the new "checkArrowOptimization throws when the check cannot run" test asserts
for `/nonexistent/python3`. This line documents only `SparkException`, so a
`private[spark]` caller that follows it would let a launch failure escape.
`canRunArrowOptimization` catches `NonFatal`, so it is unaffected today.
**Verification:**
- **Inspection:** The documented exceptions match what the check throws for
a missing executable, a timeout and an unexpected exit code.
--
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]