| # | |
| # Licensed to the Apache Software Foundation (ASF) under one or more | |
| # contributor license agreements. See the NOTICE file distributed with | |
| # this work for additional information regarding copyright ownership. | |
| # The ASF licenses this file to You under the Apache License, Version 2.0 | |
| # (the "License"); you may not use this file except in compliance with | |
| # the License. You may obtain a copy of the License at | |
| # | |
| # http://www.apache.org/licenses/LICENSE-2.0 | |
| # | |
| # Unless required by applicable law or agreed to in writing, software | |
| # distributed under the License is distributed on an "AS IS" BASIS, | |
| # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. | |
| # See the License for the specific language governing permissions and | |
| # limitations under the License. | |
| # | |
| """End-to-end tests for in-process Python UDFs. | |
| Run with python/run-tests like other SQL tests. JEP paths are discovered from the | |
| selected Python environment before the Spark JVM starts. ARROW_C_DATA_JAR must | |
| point to the provided Arrow CDI JAR. Set INPROCESS_TESTS=1 | |
| to require the suite (missing dependencies then fail), or 0 to disable it. | |
| Otherwise, the suite runs when JEP, PyArrow and the CDI JAR are available. | |
| """ | |
| import os | |
| import shutil | |
| import tempfile | |
| import unittest | |
| import zipfile | |
| from importlib.util import find_spec | |
| from pathlib import Path | |
| from unittest.mock import patch | |
| from pyspark.testing.sqlutils import ReusedSQLTestCase | |
| from pyspark.testing.utils import eventually, have_pyarrow | |
| _jep_spec = find_spec("jep") | |
| _cdi_jar = os.environ.get("ARROW_C_DATA_JAR") | |
| _test_mode = os.environ.get("INPROCESS_TESTS") | |
| _run_inprocess = _test_mode == "1" or ( | |
| _test_mode != "0" | |
| and _jep_spec is not None | |
| and have_pyarrow | |
| and _cdi_jar is not None | |
| and Path(_cdi_jar).is_file() | |
| ) | |
| @unittest.skipUnless(_run_inprocess, "Requires JEP, PyArrow and ARROW_C_DATA_JAR") | |
| class InProcessUDFTests(ReusedSQLTestCase): | |
| """ | |
| End-to-end tests for @inprocess_udf that require jep + CPython + PyArrow. | |
| The plugin initializes JEP before any task starts. Calls from task threads and | |
| shutdown must use the same dedicated interpreter thread. | |
| """ | |
| @classmethod | |
| def master(cls): | |
| return "local[2]" | |
| @classmethod | |
| def conf(cls): | |
| return ( | |
| super() | |
| .conf() | |
| .set("spark.task.cpus", "0.5") | |
| .set("spark.driver.extraClassPath", os.pathsep.join([str(cls.jep_jar), cls.cdi_jar])) | |
| .set("spark.driver.extraLibraryPath", str(cls.jep_dir)) | |
| .set( | |
| "spark.python.inProcess.sitePackages", | |
| ",".join([cls.site_packages, str(cls.jep_dir.parent)]), | |
| ) | |
| .set("spark.plugins", "org.apache.spark.sql.execution.python.InProcessPythonPlugin") | |
| ) | |
| @classmethod | |
| def setUpClass(cls): | |
| if _jep_spec is None: | |
| raise RuntimeError("INPROCESS_TESTS=1 requires JEP in the selected Python environment") | |
| # Do not import jep: it can only be imported by an embedded interpreter. | |
| cls.jep_dir = Path(_jep_spec.origin).parent | |
| jars = list(cls.jep_dir.glob("jep-*.jar")) | |
| if len(jars) != 1: | |
| raise RuntimeError(f"Expected one JEP JAR in {cls.jep_dir}, found {len(jars)}") | |
| cls.jep_jar = jars[0] | |
| if not _cdi_jar or not Path(_cdi_jar).is_file(): | |
| raise RuntimeError("Set ARROW_C_DATA_JAR to the provided Arrow CDI JAR") | |
| cls.cdi_jar = str(Path(_cdi_jar).resolve()) | |
| cls.site_packages = tempfile.mkdtemp() | |
| helper_dir = os.path.join(cls.site_packages, "extra") | |
| system_dir = os.path.join(cls.site_packages, "system") | |
| os.mkdir(helper_dir) | |
| os.mkdir(system_dir) | |
| with open(os.path.join(system_dir, "_inprocess_process_helper.py"), "w") as f: | |
| f.write("MAGIC = -1\n") | |
| with open(os.path.join(helper_dir, "_inprocess_test_helper.py"), "w") as f: | |
| f.write("MAGIC = 99\n") | |
| with open(os.path.join(cls.site_packages, "helper.pth"), "w") as f: | |
| f.write("extra\n") | |
| for name in ["spire", "redis"]: | |
| package = Path(cls.site_packages) / name | |
| package.mkdir() | |
| (package / "__init__.py").write_text("PYTHON_PACKAGE = True\n") | |
| shadow = os.path.join(cls.site_packages, "pyspark") | |
| os.mkdir(shadow) | |
| with open(os.path.join(shadow, "__init__.py"), "w") as f: | |
| f.write("raise RuntimeError('site-packages must not shadow Spark PySpark')\n") | |
| try: | |
| # CI extracts compiled targets without building pyspark.zip. Keep the source | |
| # tree available, while still requiring the configured paths to provide JEP. | |
| cls.python_source = Path(__file__).resolve().parents[3] | |
| archive = cls.python_source / "lib" / "pyspark.zip" | |
| if archive.is_file(): | |
| with zipfile.ZipFile(archive) as packaged: | |
| for module in (cls.python_source / "pyspark" / "inprocess").glob("*.py"): | |
| name = module.relative_to(cls.python_source).as_posix() | |
| if ( | |
| name not in packaged.namelist() | |
| or packaged.read(name) != module.read_bytes() | |
| ): | |
| raise RuntimeError("Rebuild or remove stale python/lib/pyspark.zip") | |
| python_path = os.pathsep.join([str(cls.python_source), system_dir]) | |
| with patch.dict(os.environ, {"PYTHONPATH": python_path}): | |
| super().setUpClass() | |
| except Exception: | |
| shutil.rmtree(cls.site_packages) | |
| raise | |
| @classmethod | |
| def tearDownClass(cls): | |
| try: | |
| super().tearDownClass() | |
| finally: | |
| shutil.rmtree(cls.site_packages) | |
| def test_driver_defined_udt_return_type(self): | |
| import sys | |
| from pyspark.inprocess import inprocess_udf | |
| from pyspark.sql.types import LongType, UserDefinedType | |
| class DriverUDT(UserDefinedType): | |
| @classmethod | |
| def sqlType(cls): | |
| return LongType() | |
| @classmethod | |
| def module(cls): | |
| return "__main__" | |
| def serialize(self, value): | |
| return value | |
| def deserialize(self, value): | |
| return value | |
| DriverUDT.__module__ = "__main__" | |
| with patch.object(sys.modules["__main__"], "DriverUDT", DriverUDT, create=True): | |
| identity = inprocess_udf(DriverUDT())(lambda x: x) | |
| result = self.spark.range(3).select(identity("id")) | |
| self.assertEqual([r[0] for r in result.collect()], [0, 1, 2]) | |
| def test_unsupported_ddl_return_type_fails_on_driver(self): | |
| from pyspark.errors import PySparkNotImplementedError | |
| from pyspark.inprocess import inprocess_udf | |
| wrapper = inprocess_udf("interval year to month")(lambda x: x) | |
| with patch("pyspark.inprocess.udf._serialize_udf") as serialize: | |
| with self.assertRaises(PySparkNotImplementedError): | |
| wrapper("id") | |
| serialize.assert_not_called() | |
| self.assertIsNone(wrapper._jfunc) | |
| def test_worker_environment_is_rejected(self): | |
| from pyspark.inprocess import inprocess_udf | |
| identity = inprocess_udf("long")(lambda x: x) | |
| column = identity("id") | |
| with self.sql_conf({"spark.pythonWorkerEnv.INPROCESS_TEST_VALUE": "value"}): | |
| # Columns are session-agnostic; validate using the query's session at execution. | |
| identity("id") | |
| with self.assertRaisesRegex(Exception, "UNSUPPORTED_IN_PROCESS_PYTHON_UDF"): | |
| self.spark.range(1).select(column).collect() | |
| def test_profiler_and_memory_settings_are_rejected(self): | |
| from pyspark.inprocess import inprocess_udf | |
| identity = inprocess_udf("long")(lambda x: x) | |
| column = identity("id") | |
| with self.sql_conf({"spark.sql.pyspark.udf.profiler": "perf"}): | |
| # Columns are session-agnostic; validate using the query's session at execution. | |
| identity("id") | |
| with self.assertRaisesRegex(Exception, "UNSUPPORTED_IN_PROCESS_PYTHON_UDF"): | |
| self.spark.range(1).select(column).collect() | |
| conf = self.spark.sparkContext._jvm.org.apache.spark.SparkEnv.get().conf() | |
| key = "spark.executor.pyspark.memory" | |
| try: | |
| conf.set(key, "128m") | |
| # Columns are session-agnostic; validate using the query's session at execution. | |
| identity("id") | |
| with self.assertRaisesRegex(Exception, "UNSUPPORTED_IN_PROCESS_PYTHON_UDF"): | |
| self.spark.range(1).select(column).collect() | |
| finally: | |
| conf.remove(key) | |
| def test_configuration_uses_the_query_session(self): | |
| from pyspark.inprocess import inprocess_udf | |
| identity = inprocess_udf("long")(lambda x: x) | |
| second = self.spark.newSession() | |
| with self.sql_conf({"spark.sql.pyspark.udf.profiler": "perf"}): | |
| column = identity("id") | |
| self.assertEqual([r[0] for r in second.range(2).select(column).collect()], [0, 1]) | |
| with self.assertRaisesRegex(Exception, "UNSUPPORTED_IN_PROCESS_PYTHON_UDF"): | |
| self.spark.range(2).select(column).collect() | |
| conf = self.spark.sparkContext._jvm.org.apache.spark.SparkEnv.get().conf() | |
| try: | |
| conf.set("spark.executor.pyspark.memory", "0") | |
| self.assertEqual(self.spark.range(1).select(identity("id")).first()[0], 0) | |
| finally: | |
| conf.remove("spark.executor.pyspark.memory") | |
| def test_python_packages_are_not_shadowed_by_java_imports(self): | |
| from pyspark.inprocess import inprocess_udf | |
| def probe(x): | |
| import sys | |
| import pyarrow as pa | |
| import redis | |
| import spire | |
| good = spire.PYTHON_PACKAGE and redis.PYTHON_PACKAGE | |
| good = good and sys.stdout.line_buffering and sys.stdout.write_through | |
| return pa.array([good] * len(x)) | |
| self.assertTrue( | |
| self.spark.range(1).select(inprocess_udf("boolean")(probe)("id")).first()[0] | |
| ) | |
| def test_result_schema_adapts_to_session_representation(self): | |
| from pyspark.inprocess import inprocess_udf | |
| def timestamp(x): | |
| import pyarrow as pa | |
| return pa.array([0] * len(x), type=pa.timestamp("us", tz="UTC")) | |
| def nested(x): | |
| import pyarrow as pa | |
| # Declare the field order: newer PyArrow versions sort inferred struct fields. | |
| return pa.array( | |
| [{"s": ["hello"], "b": b"data"}] * len(x), | |
| pa.struct([("s", pa.list_(pa.string())), ("b", pa.binary())]), | |
| ) | |
| for zone in ["UTC", "Etc/UTC", "America/Los_Angeles"]: | |
| with self.sql_conf({"spark.sql.session.timeZone": zone}): | |
| result = self.spark.range(1).select(inprocess_udf("timestamp")(timestamp)("id")) | |
| self.assertEqual( | |
| result.toDF("value").selectExpr("unix_micros(value)").first()[0], 0 | |
| ) | |
| with self.sql_conf({"spark.sql.execution.arrow.useLargeVarTypes": "true"}): | |
| result = ( | |
| self.spark.range(1) | |
| .select(inprocess_udf("struct<s:array<string>,b:binary>")(nested)("id")) | |
| .first()[0] | |
| ) | |
| self.assertEqual(result.s, ["hello"]) | |
| self.assertEqual(result.b, b"data") | |
| def test_kwargs_only_function(self): | |
| from pyspark.inprocess import inprocess_udf | |
| identity = inprocess_udf("long")(lambda **cols: cols["x"]) | |
| self.assertEqual( | |
| [row[0] for row in self.spark.range(2).select(identity(x="id")).collect()], [0, 1] | |
| ) | |
| def test_isolated_interpreter_and_explicit_process_pythonpath(self): | |
| from pyspark.inprocess import inprocess_udf | |
| def flags(x): | |
| import faulthandler | |
| import sys | |
| import _inprocess_process_helper | |
| import pyarrow as pa | |
| value = ( | |
| sys.flags.isolated == 1 | |
| and sys.flags.ignore_environment == 1 | |
| and not faulthandler.is_enabled() | |
| and _inprocess_process_helper.MAGIC == -1 | |
| ) | |
| return pa.array([value] * len(x)) | |
| self.assertTrue( | |
| self.spark.range(1).select(inprocess_udf("boolean")(flags)("id")).first()[0] | |
| ) | |
| @staticmethod | |
| def _java_command(jvm, java_home): | |
| """ | |
| The start of a command line for a fresh probe JVM: this test's java and the module | |
| options Spark's own launcher passes (JavaModuleOptions), so a probe gets the same module | |
| options as Spark's JVMs on every Java version, e.g. with Unsafe allowed for Netty on | |
| Java 25+. Only the module options are shared: the other options python/run-tests.py | |
| gives the test JVM, such as java.io.tmpdir, are not. | |
| The options start with -XX:+IgnoreUnrecognizedVMOptions, which also applies to the | |
| options a probe appends, so a misspelled -XX option does not fail the launch. | |
| """ | |
| options = jvm.org.apache.spark.launcher.JavaModuleOptions.defaultModuleOptions() | |
| return [str(Path(java_home) / "bin" / "java")] + options.split(" ") | |
| def test_missing_cdi_dependency_fails_plugin_startup(self): | |
| import subprocess | |
| jvm = self.spark.sparkContext._jvm | |
| java_home = jvm.java.lang.System.getProperty("java.home") | |
| # Spark bundles arrow-c-data too, so drop its JAR as well as the configured one. | |
| classpath = os.pathsep.join( | |
| p | |
| for p in jvm.java.lang.System.getProperty("java.class.path").split(os.pathsep) | |
| if p != self.cdi_jar and not Path(p).name.startswith("arrow-c-data") | |
| ) | |
| source = """ | |
| import java.lang.reflect.Proxy; | |
| import java.util.Collections; | |
| import org.apache.spark.SparkConf; | |
| import org.apache.spark.api.plugin.PluginContext; | |
| import org.apache.spark.sql.execution.python.InProcessPythonPlugin; | |
| class MissingCdiProbe { | |
| public static void main(String[] args) { | |
| PluginContext context = (PluginContext) Proxy.newProxyInstance( | |
| PluginContext.class.getClassLoader(), new Class<?>[] {PluginContext.class}, | |
| (proxy, method, values) -> new SparkConf(false)); | |
| try { | |
| new InProcessPythonPlugin().executorPlugin().init(context, Collections.emptyMap()); | |
| throw new AssertionError("Plugin accepted a missing CDI dependency"); | |
| } catch (IllegalStateException expected) { | |
| if (!(expected.getCause() instanceof NoClassDefFoundError)) throw expected; | |
| if (!expected.getMessage().contains("arrow-c-data.jar")) throw expected; | |
| System.out.println("MISSING_CDI_REJECTED"); | |
| } | |
| } | |
| } | |
| """ | |
| with tempfile.TemporaryDirectory() as directory: | |
| source_file = Path(directory) / "MissingCdiProbe.java" | |
| source_file.write_text(source) | |
| result = subprocess.run( | |
| self._java_command(jvm, java_home) + ["-cp", classpath, str(source_file)], | |
| capture_output=True, | |
| text=True, | |
| timeout=90, | |
| ) | |
| self.assertEqual(result.returncode, 0, result.stdout + result.stderr) | |
| self.assertIn("MISSING_CDI_REJECTED", result.stdout) | |
| def test_missing_jep_has_an_initialization_hint(self): | |
| import subprocess | |
| jvm = self.spark.sparkContext._jvm | |
| java_home = jvm.java.lang.System.getProperty("java.home") | |
| classpath = os.pathsep.join( | |
| p | |
| for p in jvm.java.lang.System.getProperty("java.class.path").split(os.pathsep) | |
| if p != str(self.jep_jar) | |
| ) | |
| source = """ | |
| import java.lang.reflect.Proxy; | |
| import java.util.Collections; | |
| import org.apache.spark.SparkConf; | |
| import org.apache.spark.api.plugin.PluginContext; | |
| import org.apache.spark.sql.execution.python.InProcessPythonPlugin; | |
| import org.apache.spark.sql.execution.python.InProcessPythonRuntime; | |
| class MissingJepProbe { | |
| public static void main(String[] args) { | |
| try { | |
| InProcessPythonRuntime.currentSession(); | |
| throw new AssertionError("Uninitialized runtime was accepted"); | |
| } catch (IllegalStateException expected) { | |
| if (!expected.getMessage().contains("executor plugin")) throw expected; | |
| } | |
| PluginContext context = (PluginContext) Proxy.newProxyInstance( | |
| PluginContext.class.getClassLoader(), new Class<?>[] {PluginContext.class}, | |
| (proxy, method, values) -> new SparkConf(false)); | |
| try { | |
| new InProcessPythonPlugin().executorPlugin().init(context, Collections.emptyMap()); | |
| throw new AssertionError("Plugin accepted a missing JEP dependency"); | |
| } catch (IllegalStateException expected) { | |
| if (!(expected.getCause() instanceof LinkageError)) throw expected; | |
| if (!expected.getMessage().contains("jep.jar")) throw expected; | |
| System.out.println("MISSING_JEP_REJECTED"); | |
| } | |
| } | |
| } | |
| """ | |
| with tempfile.TemporaryDirectory() as directory: | |
| source_file = Path(directory) / "MissingJepProbe.java" | |
| source_file.write_text(source) | |
| result = subprocess.run( | |
| self._java_command(jvm, java_home) | |
| + [ | |
| "-cp", | |
| classpath, | |
| str(source_file), | |
| ], | |
| capture_output=True, | |
| text=True, | |
| timeout=90, | |
| ) | |
| self.assertEqual(result.returncode, 0, result.stdout + result.stderr) | |
| self.assertIn("MISSING_JEP_REJECTED", result.stdout) | |
| def test_fresh_jvm_retries_configuration_without_spark_home(self): | |
| import subprocess | |
| import sysconfig | |
| import venv | |
| from pyspark import cloudpickle | |
| jvm = self.spark.sparkContext._jvm | |
| java_home = jvm.java.lang.System.getProperty("java.home") | |
| classpath = jvm.java.lang.System.getProperty("java.class.path") | |
| spark_home = jvm.java.lang.System.getenv("SPARK_HOME") | |
| self.assertIsNotNone(spark_home) | |
| python_lib = Path(spark_home) / "python" / "lib" | |
| env = os.environ.copy() | |
| env.pop("SPARK_HOME", None) | |
| env.pop("VIRTUAL_ENV", None) | |
| env["PATH"] = os.defpath | |
| # These flags must be ignored by the embedded interpreter. No signals are raised. | |
| env["PYTHONFAULTHANDLER"] = "1" | |
| env["PYTHONDEVMODE"] = "1" | |
| env["LC_ALL"] = "C" | |
| env["LANG"] = "C" | |
| # The failed configuration also lists a default sys.path entry, which a retry keeps. | |
| stdlib = os.path.realpath(sysconfig.get_paths()["stdlib"]) | |
| class BootstrapCheck: | |
| def __reduce__(self): | |
| return eval, ( | |
| "(__import__('sys').flags.isolated == 1 and " | |
| f"{stdlib!r} in [__import__('os').path.realpath(p) " | |
| "for p in __import__('sys').path] and " | |
| "__import__('sys').flags.ignore_environment == 1 and " | |
| "not __import__('faulthandler').is_enabled() and " | |
| "'pyspark.zip' in __import__('pyspark').__file__ and " | |
| "__import__('sys').stdout.line_buffering and " | |
| "__import__('sys').stdout.write_through and " | |
| "__import__('importlib.util', fromlist=['find_spec'])" | |
| ".find_spec('only_in_failed_config') is None and " | |
| "(print('SHUTDOWN_FLUSH', end='') or (lambda x: x))) or " | |
| "(_ for _ in ()).throw(AssertionError('unexpected bootstrap state'))", | |
| ) | |
| source = """ | |
| import java.nio.file.Files; | |
| import java.nio.file.Path; | |
| import java.util.Arrays; | |
| import org.apache.arrow.vector.types.pojo.Field; | |
| import org.apache.spark.sql.execution.python.InProcessPythonRuntime; | |
| import org.apache.spark.sql.types.DataTypes; | |
| import org.apache.spark.sql.util.ArrowUtils; | |
| class BootstrapProbe { | |
| public static void main(String[] args) throws Exception { | |
| var bad = scala.jdk.javaapi.CollectionConverters | |
| .asScala(Arrays.asList(args[0].split(java.io.File.pathSeparator))).toSeq(); | |
| boolean failed = false; | |
| try { | |
| InProcessPythonRuntime.initialize(bad); | |
| } catch (jep.JepException expected) { | |
| failed = true; | |
| } | |
| if (!failed) throw new AssertionError("Expected missing JEP package"); | |
| // A configured directory that did not exist when the first attempt failed. | |
| Files.createSymbolicLink(Path.of(args[5]), Path.of(args[2])); | |
| var good = scala.jdk.javaapi.CollectionConverters | |
| .asScala(Arrays.asList(args[1], args[5])).toSeq(); | |
| try { | |
| InProcessPythonRuntime.initialize(good); | |
| Field field = ArrowUtils.toArrowField("result", DataTypes.LongType, true, "UTC", | |
| false, org.apache.spark.sql.types.Metadata.empty(), false); | |
| InProcessPythonRuntime.currentSession().register("probe", | |
| Files.readAllBytes(Path.of(args[3])), field, args[4], false, false, false, true); | |
| if (ArrowUtils.rootAllocator().getAllocatedMemory() != 0) { | |
| throw new AssertionError("Unreleased registration schema"); | |
| } | |
| System.out.println("BOOTSTRAP_OK"); | |
| } finally { | |
| InProcessPythonRuntime.currentSession().release( | |
| scala.jdk.javaapi.CollectionConverters.asScala(Arrays.asList("probe")).toSeq()); | |
| InProcessPythonRuntime.shutdown(); | |
| } | |
| } | |
| } | |
| """ | |
| with tempfile.TemporaryDirectory() as directory: | |
| # Keep JEP absent initially even when it is installed in the system Python. | |
| clean_python = Path(directory) / "python" | |
| venv.EnvBuilder(with_pip=False).create(clean_python) | |
| env["PATH"] = str(clean_python / "bin") + os.pathsep + os.defpath | |
| # A module that only the failed configuration's directory has, which the retried | |
| # configuration must not import. | |
| (Path(directory) / "only_in_failed_config.py").write_text("") | |
| late = Path(directory) / "created_after_failure" | |
| # Without a packaged zip, e.g. in CI, package the sources for this process. | |
| packaged = python_lib / "pyspark.zip" | |
| if not packaged.is_file(): | |
| packaged = Path(directory) / "pyspark.zip" | |
| with zipfile.ZipFile(packaged, "w") as archive: | |
| sources = Path(spark_home) / "python" / "pyspark" | |
| for entry in sources.rglob("*"): | |
| if entry.is_file() and "__pycache__" not in entry.parts: | |
| archive.write(entry, entry.relative_to(sources.parent)) | |
| env["PYTHONPATH"] = os.pathsep.join( | |
| [str(packaged)] + [str(p) for p in python_lib.glob("py4j-*.zip")] | |
| ) | |
| source_file = Path(directory) / "BootstrapProbe.java" | |
| source_file.write_text(source) | |
| command_file = Path(directory) / "command.pickle" | |
| command_file.write_bytes(cloudpickle.dumps(BootstrapCheck())) | |
| import sys | |
| result = subprocess.run( | |
| self._java_command(jvm, java_home) | |
| + [ | |
| f"-Djava.library.path={self.jep_dir}", | |
| "--class-path", | |
| classpath, | |
| str(source_file), | |
| os.pathsep.join([directory, stdlib, str(late)]), | |
| self.site_packages, | |
| str(self.jep_dir.parent), | |
| str(command_file), | |
| "%d.%d" % sys.version_info[:2], | |
| str(late), | |
| ], | |
| env=env, | |
| capture_output=True, | |
| text=True, | |
| timeout=90, | |
| ) | |
| self.assertEqual(result.returncode, 0, result.stdout + result.stderr) | |
| self.assertIn("BOOTSTRAP_OK", result.stdout) | |
| self.assertIn("SHUTDOWN_FLUSH", result.stdout) | |
| self.assertIn("set LC_ALL=C.UTF-8", result.stderr) | |
| def test_failed_bootstrap_preserves_message_and_freezes_site_packages(self): | |
| import subprocess | |
| jvm = self.spark.sparkContext._jvm | |
| java_home = jvm.java.lang.System.getProperty("java.home") | |
| classpath = jvm.java.lang.System.getProperty("java.class.path") | |
| source = """ | |
| import java.util.Arrays; | |
| import org.apache.spark.sql.execution.python.InProcessPythonRuntime; | |
| class BootstrapFailureProbe { | |
| public static void main(String[] args) { | |
| var paths = scala.jdk.javaapi.CollectionConverters | |
| .asScala(Arrays.asList(args[0], args[1])).toSeq(); | |
| try { | |
| InProcessPythonRuntime.initialize(paths); | |
| throw new AssertionError("Expected an unsupported PyArrow version"); | |
| } catch (jep.JepException expected) { | |
| String message = expected.getMessage(); | |
| if (!message.contains("PySparkImportError") || | |
| !message.contains("UNSUPPORTED_PACKAGE_VERSION") || | |
| !message.contains("PyArrow") || !message.contains("0.0.0")) throw expected; | |
| } | |
| var changed = scala.jdk.javaapi.CollectionConverters | |
| .asScala(Arrays.asList(args[1])).toSeq(); | |
| try { | |
| InProcessPythonRuntime.initialize(changed); | |
| throw new AssertionError("Accepted a different environment after bootstrap failure"); | |
| } catch (IllegalStateException expected) { | |
| if (!expected.getMessage().contains("Restart the executor process")) throw expected; | |
| } | |
| System.out.println("BOOTSTRAP_FAILURE_CHECKED"); | |
| } | |
| } | |
| """ | |
| with tempfile.TemporaryDirectory() as directory: | |
| # Use a backslash in a supported POSIX path to exercise JEP's escaping too. | |
| packages = Path(directory) / "back\\slash" | |
| packages.mkdir() | |
| (packages / "old_arrow.pth").write_text( | |
| "import pyarrow; pyarrow.__version__ = '0.0.0'\n" | |
| ) | |
| source_file = Path(directory) / "BootstrapFailureProbe.java" | |
| source_file.write_text(source) | |
| env = os.environ.copy() | |
| # This direct JVM probe needs no Spark installation and always imports source. | |
| # Py4J comes from the same place as in this process, e.g. Spark's source zip. | |
| import py4j | |
| env["SPARK_HOME"] = directory | |
| env["PYTHONPATH"] = os.pathsep.join( | |
| [str(self.python_source), str(Path(py4j.__file__).parents[1])] | |
| ) | |
| result = subprocess.run( | |
| self._java_command(jvm, java_home) | |
| + [ | |
| f"-Djava.library.path={self.jep_dir}", | |
| "--class-path", | |
| classpath, | |
| str(source_file), | |
| str(packages), | |
| str(self.jep_dir.parent), | |
| ], | |
| env=env, | |
| capture_output=True, | |
| text=True, | |
| timeout=90, | |
| ) | |
| self.assertEqual(result.returncode, 0, result.stdout + result.stderr) | |
| self.assertIn("BOOTSTRAP_FAILURE_CHECKED", result.stdout) | |
| def test_exception_unicode_and_nul_survive_jep(self): | |
| from pyspark.inprocess import inprocess_udf | |
| def fail(x): | |
| raise ValueError("failure: caf\u00e9 \u4e2d\u6587 \U0001f600 \ud800 \0 tail") | |
| with self.assertRaises(Exception) as error: | |
| self.spark.range(1).select(inprocess_udf("long")(fail)("id")).collect() | |
| self.assertIn("caf\u00e9 \u4e2d\u6587", str(error.exception)) | |
| self.assertIn(r"\U0001f600 \ud800 \x00 tail", str(error.exception)) | |
| def test_named_argument_resolver(self): | |
| from pyspark.inprocess import inprocess_udf | |
| identity = inprocess_udf("long")(lambda x, **kw: x) | |
| with self.sql_conf({"spark.sql.caseSensitive": "false"}): | |
| with self.assertRaisesRegex(Exception, "DOUBLE_NAMED_ARGUMENT_REFERENCE"): | |
| identity(x="id", X="id") | |
| with self.sql_conf({"spark.sql.caseSensitive": "true"}): | |
| result = self.spark.range(2).select(identity(x="id", X="id")) | |
| self.assertEqual([r[0] for r in result.collect()], [0, 1]) | |
| def test_spark_python_distribution_precedes_site_packages(self): | |
| import pyarrow as pa | |
| from pyspark.inprocess import inprocess_udf | |
| def location(x): | |
| import pyspark | |
| return pa.array([pyspark.__file__] * len(x)) | |
| path = self.spark.range(1).select(inprocess_udf("string")(location)("id")).first()[0] | |
| expected = str(self.python_source / "pyspark" / "__init__.py") | |
| packaged = str(self.python_source / "lib" / "pyspark.zip" / "pyspark" / "__init__.py") | |
| self.assertIn(path, [expected, packaged]) | |
| def test_declared_struct_metadata(self): | |
| from pyspark.inprocess import inprocess_udf | |
| from pyspark.sql.types import LongType, StructField, StructType | |
| declared = StructType([StructField("x", LongType(), metadata={"comment": "c"})]) | |
| identity = inprocess_udf(declared)(lambda x: x) | |
| df = self.spark.sql("SELECT named_struct('x', 7L) AS value") | |
| result = df.select(identity("value").alias("value")) | |
| self.assertEqual(result.collect(), df.collect()) | |
| self.assertEqual(result.schema[0].dataType, declared) | |
| def test_temporal_precision_metadata(self): | |
| from pyspark.inprocess import inprocess_udf | |
| for declared in ["time(3)", "timestamp_ntz(7)", "timestamp_ltz(8)"]: | |
| literal = "12:34:56.123" if declared.startswith("time(") else "2024-01-02 12:34:56.123" | |
| for nested in [False, True]: | |
| with self.subTest(declared=declared, nested=nested): | |
| df = self.spark.sql(f"SELECT CAST('{literal}' AS {declared}) AS value") | |
| if nested: | |
| df = df.selectExpr("named_struct('t', value) AS value") | |
| identity = inprocess_udf(df.schema[0].dataType)(lambda x: x) | |
| result = df.select(identity("value").alias("value")) | |
| self.assertEqual(result.schema[0].dataType, df.schema[0].dataType) | |
| self.assertEqual( | |
| result.selectExpr("CAST(value AS STRING)").collect(), | |
| df.selectExpr("CAST(value AS STRING)").collect(), | |
| ) | |
| def test_large_types_variant_and_spatial_identity(self): | |
| from pyspark.inprocess import inprocess_udf | |
| from pyspark.sql.types import Geography, GeographyType, Geometry, GeometryType | |
| wkb = bytes.fromhex("010100000000000000000031400000000000001c40") | |
| frames = [ | |
| self.spark.sql("SELECT parse_json('{\"a\":1}') AS value"), | |
| self.spark.createDataFrame([(Geometry(wkb, 0),)], "value geometry(0)"), | |
| self.spark.createDataFrame([(Geography(wkb, 4326),)], "value geography(4326)"), | |
| ] | |
| for large in ["false", "true"]: | |
| with self.sql_conf({"spark.sql.execution.arrow.useLargeVarTypes": large}): | |
| for df in frames: | |
| with self.subTest(large=large, data_type=df.schema[0].dataType): | |
| identity = inprocess_udf(df.schema[0].dataType)(lambda x: x) | |
| result = df.select(identity("value").alias("value")) | |
| if isinstance(df.schema[0].dataType, (GeometryType, GeographyType)): | |
| self.assertEqual(result.collect(), df.collect()) | |
| else: | |
| self.assertEqual( | |
| result.selectExpr("CAST(value AS STRING)").collect(), | |
| df.selectExpr("CAST(value AS STRING)").collect(), | |
| ) | |
| def test_ddl_return_type_and_nondeterminism(self): | |
| from pyspark.inprocess import inprocess_udf | |
| identity = inprocess_udf("long")(lambda x: x).asNondeterministic() | |
| self.assertEqual(self.spark.range(2).select(identity("id")).first()[0], 0) | |
| plan = self.spark.range(1).select(identity("id"))._jdf.queryExecution().analyzed() | |
| self.assertFalse(plan.expressions().apply(0).deterministic()) | |
| def test_command_is_stable_after_the_first_call(self): | |
| import pyarrow as pa | |
| from pyspark.inprocess import inprocess_udf | |
| # Like other Python UDFs, the command is serialized once, on the first call. | |
| namespace = {"inprocess_udf": inprocess_udf, "pa": pa} | |
| exec( | |
| "@inprocess_udf('long')\n" | |
| "def f(x): return pa.array([LOOKUP] * len(x), type=pa.int64())\n", | |
| namespace, | |
| ) | |
| wrapper = namespace["f"] | |
| namespace["LOOKUP"] = 42 | |
| first = wrapper("id") | |
| namespace["LOOKUP"] = 99 | |
| rows = self.spark.range(1).select(first, wrapper("id")).collect() | |
| self.assertEqual(tuple(rows[0]), (42, 42)) | |
| def test_calls_share_one_jvm_function(self): | |
| import copy | |
| import pyarrow.compute as pc | |
| from pyspark.inprocess import inprocess_udf | |
| jvm = self.spark._jvm | |
| double = inprocess_udf("long")(lambda x: pc.multiply(x, 2)) | |
| def functions(*cols): | |
| plan = self.spark.range(1).select(*cols)._jdf.queryExecution().analyzed() | |
| udfs = [plan.expressions().apply(i).child() for i in range(len(cols))] | |
| return [jvm.System.identityHashCode(udf.func()) for udf in udfs], udfs | |
| # The command crosses py4j once, however many expressions call the UDF. | |
| ids, _ = functions(double("id"), double("id")) | |
| self.assertEqual(ids[0], ids[1]) | |
| # Nondeterminism belongs to each call, so it keeps the function. | |
| double.asNondeterministic() | |
| later, udfs = functions(double("id")) | |
| self.assertEqual(later[0], ids[0]) | |
| self.assertFalse(udfs[0].udfDeterministic()) | |
| # A called wrapper can still be copied; the copy creates its own function. | |
| copied = copy.deepcopy(double) | |
| self.assertIsNone(copied._jfunc) | |
| self.assertEqual(self.spark.range(2).select(copied("id")).collect()[1][0], 2) | |
| def test_traceback_settings_are_per_registration(self): | |
| from pyspark.inprocess import inprocess_udf | |
| # User frames must be outside the pyspark package for the worker's simplifier. | |
| namespace = {} | |
| exec( | |
| "def probe(x):\n probe_local = 8675309\n" | |
| " raise ValueError('traceback policy probe')", | |
| namespace, | |
| ) | |
| traceback_probe = inprocess_udf("long")(namespace["probe"]) | |
| for hide, simplified, locals_enabled in [ | |
| (True, False, False), | |
| (False, True, False), | |
| (False, False, False), | |
| (True, True, True), | |
| (False, True, True), | |
| (False, False, True), | |
| ]: | |
| with self.sql_conf( | |
| { | |
| "spark.sql.execution.pyspark.udf.tracebackWithLocals.enabled": str( | |
| locals_enabled | |
| ).lower(), | |
| "spark.sql.execution.pyspark.udf.hideTraceback.enabled": str(hide).lower(), | |
| "spark.sql.execution.pyspark.udf.simplifiedTraceback.enabled": str( | |
| simplified | |
| ).lower(), | |
| } | |
| ): | |
| with self.assertRaises(Exception) as error: | |
| self.spark.range(1).select(traceback_probe("id")).collect() | |
| message = str(error.exception) | |
| self.assertIn("ValueError: traceback policy probe", message) | |
| self.assertEqual('File "' in message, not hide) | |
| self.assertEqual("probe_local = 8675309" in message, locals_enabled and not hide) | |
| if not hide: | |
| self.assertEqual("inprocess/runtime.py" in message, not simplified) | |
| def test_embedded_hash_seed_matches_default_worker_seed(self): | |
| import subprocess | |
| import sys | |
| import pyarrow as pa | |
| from pyspark.inprocess import inprocess_udf | |
| expected = int( | |
| subprocess.check_output( | |
| [sys.executable, "-c", "print(hash('spark'))"], | |
| env={**os.environ, "PYTHONHASHSEED": "0"}, | |
| ) | |
| ) | |
| hash_udf = inprocess_udf("long")( | |
| lambda x: pa.array([hash("spark")] * len(x), type=pa.int64()) | |
| ) | |
| rows = self.spark.range(4, numPartitions=2).select(hash_udf("id")).collect() | |
| self.assertEqual([row[0] for row in rows], [expected] * 4) | |
| def test_expression_arguments_and_multiple_batches(self): | |
| import pyarrow.compute as pc | |
| from pyspark.inprocess import inprocess_udf | |
| from pyspark.sql.functions import lit | |
| from pyspark.sql.types import LongType | |
| add = inprocess_udf(LongType())(lambda x, y: pc.add(x, y)) | |
| with self.sql_conf({"spark.sql.execution.arrow.maxRecordsPerBatch": "2"}): | |
| df = self.spark.range(11, numPartitions=3) | |
| values = df.select(add(df.id + 1, lit(2).cast("long"))).collect() | |
| self.assertEqual([r[0] for r in values], list(range(3, 14))) | |
| def test_preserved_child_columns_produce_collectable_rows(self): | |
| from pyspark.inprocess import inprocess_udf | |
| from pyspark.sql.types import LongType | |
| identity = inprocess_udf(LongType())(lambda x: x) | |
| df = self.spark.range(3) | |
| rows = df.select(df.id, identity(df.id)).collect() | |
| self.assertEqual([tuple(r) for r in rows], [(0, 0), (1, 1), (2, 2)]) | |
| def test_unlimited_batch_size(self): | |
| from pyspark.inprocess import inprocess_udf | |
| from pyspark.sql.types import LongType | |
| identity = inprocess_udf(LongType())(lambda x: x) | |
| for batch_size in (0, -1): | |
| with ( | |
| self.subTest(batch_size=batch_size), | |
| self.sql_conf({"spark.sql.execution.arrow.maxRecordsPerBatch": str(batch_size)}), | |
| ): | |
| df = self.spark.range(5) | |
| values = [r[0] for r in df.select(identity(df.id)).collect()] | |
| self.assertEqual(values, list(range(5))) | |
| def test_byte_limit_applies_without_a_row_limit(self): | |
| import pyarrow as pa | |
| from pyspark.inprocess import inprocess_udf | |
| from pyspark.sql.types import LongType | |
| lengths = inprocess_udf(LongType())(lambda x: pa.array([len(x)] * len(x), type=pa.int64())) | |
| with self.sql_conf( | |
| { | |
| "spark.sql.execution.arrow.maxRecordsPerBatch": "0", | |
| "spark.sql.execution.arrow.maxBytesPerBatch": "1", | |
| } | |
| ): | |
| df = self.spark.range(4) | |
| self.assertEqual([r[0] for r in df.select(lengths(df.id)).collect()], [1] * 4) | |
| def test_wrong_result_length_fails_before_rows_are_read(self): | |
| from pyspark.inprocess import inprocess_udf | |
| from pyspark.sql.types import LongType | |
| short = inprocess_udf(LongType())(lambda x: x.slice(0, len(x) - 1)) | |
| df = self.spark.range(3, numPartitions=1) | |
| with self.assertRaisesRegex(Exception, "returned 2 rows; expected 3"): | |
| df.select(short(df.id)).collect() | |
| def test_numpy_finalizers_run_on_the_interpreter_thread(self): | |
| from pyspark.inprocess import inprocess_udf | |
| with tempfile.TemporaryDirectory() as directory: | |
| marker = str(Path(directory) / "finalizers.txt") | |
| def produce(x): | |
| import threading | |
| import weakref | |
| import numpy as np | |
| import pyarrow as pa | |
| owner = threading.get_ident() | |
| def finalized(): | |
| with open(marker, "a") as stream: | |
| stream.write(f"{owner} {threading.get_ident()}\n") | |
| values = np.arange(len(x), dtype=np.int64) | |
| weakref.finalize(values, finalized) | |
| return pa.array(values) | |
| with self.sql_conf({"spark.sql.execution.arrow.maxRecordsPerBatch": "2"}): | |
| result = ( | |
| self.spark.range(8, numPartitions=2) | |
| .select(inprocess_udf("long")(produce)("id")) | |
| .collect() | |
| ) | |
| self.assertEqual(len(result), 8) | |
| # A subsequent invocation waits behind cleanup already queued by completed tasks. | |
| identity = inprocess_udf("long")(lambda x: x) | |
| self.spark.range(1).select(identity("id")).collect() | |
| records = Path(marker).read_text().splitlines() | |
| self.assertEqual(len(records), 4) | |
| for record in records: | |
| owner, finalizer = record.split() | |
| self.assertEqual(owner, finalizer) | |
| def test_arrow_memory_is_released_on_success_limit_and_failure(self): | |
| from pyspark.inprocess import inprocess_udf | |
| from pyspark.sql.types import LongType | |
| identity = inprocess_udf(LongType())(lambda x: x) | |
| @inprocess_udf(LongType()) | |
| def fail(x): | |
| raise ValueError("second UDF failed") | |
| arrow_utils = self.spark.sparkContext._jvm.org.apache.spark.sql.util.ArrowUtils | |
| allocator = arrow_utils.rootAllocator() | |
| before = allocator.getAllocatedMemory() | |
| # The interpreter thread releases Python's references after the query returns, and a | |
| # failed job does not wait for its other tasks to release their memory. | |
| @eventually(timeout=30.0, catch_assertions=True) | |
| def assert_released(): | |
| self.assertEqual(allocator.getAllocatedMemory(), before) | |
| with self.sql_conf({"spark.sql.execution.arrow.maxRecordsPerBatch": "2"}): | |
| df = self.spark.range(9, numPartitions=3) | |
| for _ in range(3): | |
| self.assertEqual(df.select(identity(df.id)).collect()[0][0], 0) | |
| assert_released() | |
| # The filter keeps the limit above the in-process node, which then stops early. | |
| df.select(identity(df.id).alias("v")).filter("v >= 0").limit(1).collect() | |
| assert_released() | |
| with self.assertRaisesRegex(Exception, "second UDF failed"): | |
| df.select(identity(df.id), fail(df.id)).collect() | |
| assert_released() | |
| # Deserialization fails before Python imports any input CDI structures. | |
| broken = inprocess_udf(LongType())(lambda x: x) | |
| with patch("pyspark.inprocess.udf._serialize_udf", return_value=b"invalid pickle"): | |
| column = broken(df.id) | |
| with self.assertRaisesRegex(Exception, "UnpicklingError"): | |
| df.select(column).collect() | |
| assert_released() | |
| def test_double_long(self): | |
| """@inprocess_udf with LongType input/output doubles each value.""" | |
| import pyarrow.compute as pc | |
| from pyspark.inprocess.udf import inprocess_udf | |
| from pyspark.sql.types import LongType | |
| @inprocess_udf(returnType=LongType()) | |
| def double(x): | |
| return pc.multiply(x, 2) | |
| df = self.spark.range(1, 6) # [1, 2, 3, 4, 5] | |
| result = df.select(double(df["id"])).collect() | |
| self.assertEqual([r[0] for r in result], [2, 4, 6, 8, 10]) | |
| def test_negate_double(self): | |
| """@inprocess_udf with DoubleType negates each value.""" | |
| import pyarrow.compute as pc | |
| from pyspark.inprocess.udf import inprocess_udf | |
| from pyspark.sql.types import DoubleType | |
| @inprocess_udf(returnType=DoubleType()) | |
| def negate(x): | |
| return pc.negate(x) | |
| data = [(1.5,), (2.5,), (3.0,)] | |
| df = self.spark.createDataFrame(data, ["v"]) | |
| result = [r[0] for r in df.select(negate(df["v"])).collect()] | |
| self.assertAlmostEqual(result[0], -1.5) | |
| self.assertAlmostEqual(result[1], -2.5) | |
| self.assertAlmostEqual(result[2], -3.0) | |
| def test_identity_integer(self): | |
| """@inprocess_udf with IntegerType passes values through unchanged.""" | |
| from pyspark.inprocess.udf import inprocess_udf | |
| from pyspark.sql.types import IntegerType | |
| @inprocess_udf(returnType=IntegerType()) | |
| def identity(x): | |
| return x | |
| data = [(i,) for i in range(5)] | |
| df = self.spark.createDataFrame(data, "v int") | |
| result = [r[0] for r in df.select(identity(df["v"])).collect()] | |
| self.assertEqual(result, list(range(5))) | |
| def test_boolean_not(self): | |
| """@inprocess_udf with BooleanType inverts each boolean.""" | |
| import pyarrow.compute as pc | |
| from pyspark.inprocess.udf import inprocess_udf | |
| from pyspark.sql.types import BooleanType | |
| @inprocess_udf(returnType=BooleanType()) | |
| def invert(x): | |
| return pc.invert(x) | |
| data = [(True,), (False,), (True,)] | |
| df = self.spark.createDataFrame(data, ["v"]) | |
| result = [r[0] for r in df.select(invert(df["v"])).collect()] | |
| self.assertEqual(result, [False, True, False]) | |
| # ------------------------------------------------------------------ | |
| # Null handling | |
| # ------------------------------------------------------------------ | |
| def test_null_passthrough(self): | |
| """Null values in the input must produce null in the output.""" | |
| import pyarrow.compute as pc | |
| from pyspark.inprocess.udf import inprocess_udf | |
| from pyspark.sql.types import LongType | |
| @inprocess_udf(returnType=LongType()) | |
| def negate(x): | |
| return pc.negate(x) | |
| data = [1, None, 3] | |
| df = self.spark.createDataFrame([(v,) for v in data], ["v"]) | |
| rows = df.select(negate(df["v"])).collect() | |
| self.assertEqual(rows[0][0], -1) | |
| self.assertIsNone(rows[1][0]) | |
| self.assertEqual(rows[2][0], -3) | |
| def test_all_nulls(self): | |
| """Column of all-null values: every output row must be null.""" | |
| import pyarrow.compute as pc | |
| from pyspark.inprocess.udf import inprocess_udf | |
| from pyspark.sql.types import LongType, StructField, StructType | |
| @inprocess_udf(returnType=LongType()) | |
| def double(x): | |
| return pc.multiply(x, 2) | |
| schema = StructType([StructField("v", LongType(), nullable=True)]) | |
| data = [(None,), (None,), (None,)] | |
| df = self.spark.createDataFrame(data, schema) | |
| rows = df.select(double(df["v"])).collect() | |
| for row in rows: | |
| self.assertIsNone(row[0]) | |
| # ------------------------------------------------------------------ | |
| # Multi-column UDFs | |
| # ------------------------------------------------------------------ | |
| def test_two_column_add(self): | |
| """UDF that adds two LongType columns together.""" | |
| import pyarrow.compute as pc | |
| from pyspark.inprocess.udf import inprocess_udf | |
| from pyspark.sql.types import LongType | |
| @inprocess_udf(returnType=LongType()) | |
| def add(a, b): | |
| return pc.add(a, b) | |
| data = [(1, 10), (2, 20), (3, 30)] | |
| df = self.spark.createDataFrame(data, ["a", "b"]) | |
| result = [r[0] for r in df.select(add(df["a"], df["b"])).collect()] | |
| self.assertEqual(result, [11, 22, 33]) | |
| def test_two_column_multiply(self): | |
| """UDF that multiplies two DoubleType columns.""" | |
| import pyarrow.compute as pc | |
| from pyspark.inprocess.udf import inprocess_udf | |
| from pyspark.sql.types import DoubleType | |
| @inprocess_udf(returnType=DoubleType()) | |
| def multiply(a, b): | |
| return pc.multiply(a, b) | |
| data = [(2.0, 3.0), (4.0, 5.0)] | |
| df = self.spark.createDataFrame(data, ["a", "b"]) | |
| result = [r[0] for r in df.select(multiply(df["a"], df["b"])).collect()] | |
| self.assertAlmostEqual(result[0], 6.0) | |
| self.assertAlmostEqual(result[1], 20.0) | |
| # ------------------------------------------------------------------ | |
| # UDF reuse and multiple UDFs on the same query | |
| # ------------------------------------------------------------------ | |
| def test_two_udfs_same_select(self): | |
| """Two different @inprocess_udf calls in the same select are both executed.""" | |
| import pyarrow.compute as pc | |
| from pyspark.inprocess.udf import inprocess_udf | |
| from pyspark.sql.types import LongType | |
| @inprocess_udf(returnType=LongType()) | |
| def double(x): | |
| return pc.multiply(x, 2) | |
| @inprocess_udf(returnType=LongType()) | |
| def triple(x): | |
| return pc.multiply(x, 3) | |
| df = self.spark.range(1, 4) # [1, 2, 3] | |
| rows = df.select(double(df["id"]), triple(df["id"])).collect() | |
| self.assertEqual([r[0] for r in rows], [2, 4, 6]) | |
| self.assertEqual([r[1] for r in rows], [3, 6, 9]) | |
| def test_udf_reuse_across_queries(self): | |
| """The same InProcessUDFWrapper can be applied to different DataFrames.""" | |
| import pyarrow.compute as pc | |
| from pyspark.inprocess.udf import inprocess_udf | |
| from pyspark.sql.types import LongType | |
| @inprocess_udf(returnType=LongType()) | |
| def double(x): | |
| return pc.multiply(x, 2) | |
| df1 = self.spark.range(1, 4) | |
| df2 = self.spark.range(10, 13) | |
| result1 = [r[0] for r in df1.select(double(df1["id"])).collect()] | |
| result2 = [r[0] for r in df2.select(double(df2["id"])).collect()] | |
| self.assertEqual(result1, [2, 4, 6]) | |
| self.assertEqual(result2, [20, 22, 24]) | |
| # ------------------------------------------------------------------ | |
| # Concurrency | |
| # ------------------------------------------------------------------ | |
| def test_concurrent_tasks_have_separate_function_state(self): | |
| import pyarrow as pa | |
| from pyspark.inprocess import inprocess_udf | |
| from pyspark.sql.types import LongType | |
| state = [] | |
| @inprocess_udf(LongType(), deterministic=False) | |
| def counter(x): | |
| state.append(1) | |
| return pa.array([len(state)] * len(x), type=pa.int64()) | |
| with self.sql_conf({"spark.sql.execution.arrow.maxRecordsPerBatch": "2"}): | |
| df = self.spark.range(8, numPartitions=2) | |
| for _ in range(2): | |
| self.assertEqual( | |
| [r[0] for r in df.select(counter(df.id)).collect()], | |
| [1, 1, 2, 2, 1, 1, 2, 2], | |
| ) | |
| # ------------------------------------------------------------------ | |
| # Closure capture | |
| # ------------------------------------------------------------------ | |
| def test_udf_captures_closure(self): | |
| """UDF closure values defined in outer scope are serialized correctly.""" | |
| import pyarrow.compute as pc | |
| from pyspark.inprocess.udf import inprocess_udf | |
| from pyspark.sql.types import LongType | |
| factor = 7 # captured in closure | |
| @inprocess_udf(returnType=LongType()) | |
| def scale(x): | |
| return pc.multiply(x, factor) | |
| df = self.spark.range(1, 4) | |
| result = [r[0] for r in df.select(scale(df["id"])).collect()] | |
| self.assertEqual(result, [7, 14, 21]) | |
| # ------------------------------------------------------------------ | |
| # Non-deterministic flag | |
| # ------------------------------------------------------------------ | |
| def test_nondeterministic_udf_executes(self): | |
| """A UDF declared deterministic=False executes and returns correct values.""" | |
| import pyarrow.compute as pc | |
| from pyspark.inprocess.udf import inprocess_udf | |
| from pyspark.sql.types import LongType | |
| @inprocess_udf(returnType=LongType(), deterministic=False) | |
| def double(x): | |
| return pc.multiply(x, 2) | |
| df = self.spark.range(1, 4) | |
| result = [r[0] for r in df.select(double(df["id"])).collect()] | |
| self.assertEqual(result, [2, 4, 6]) | |
| def test_nondeterministic_flag_propagates_to_expression(self): | |
| """deterministic=False must be reflected in the PythonUDF expression.""" | |
| import pyarrow.compute as pc | |
| from pyspark.inprocess.udf import inprocess_udf | |
| from pyspark.sql.types import LongType | |
| @inprocess_udf(returnType=LongType(), deterministic=False) | |
| def double(x): | |
| return pc.multiply(x, 2) | |
| df = self.spark.range(3) | |
| jdf = df.select(double(df["id"]))._jdf | |
| # Python UDF extraction happens during optimization. | |
| optimized = jdf.queryExecution().optimizedPlan() | |
| # Walk the logical plan via children() (no PartialFunction needed) | |
| # to locate the ArrowEvalPython node inserted during optimization. | |
| def find_node(plan): | |
| if plan.getClass().getSimpleName() == "ArrowEvalPython": | |
| return plan | |
| children = plan.children().toList() | |
| for i in range(children.length()): | |
| found = find_node(children.apply(i)) | |
| if found is not None: | |
| return found | |
| return None | |
| inprocess_node = find_node(optimized) | |
| self.assertIsNotNone(inprocess_node, "ArrowEvalPython not found in analyzed plan") | |
| udfs = inprocess_node.udfs().toList() | |
| self.assertGreater(udfs.length(), 0) | |
| self.assertFalse( | |
| udfs.apply(0).deterministic(), | |
| "PythonUDF with deterministic=False must have deterministic()==False", | |
| ) | |
| # ------------------------------------------------------------------ | |
| # String type | |
| # ------------------------------------------------------------------ | |
| def test_string_identity(self): | |
| """@inprocess_udf with StringType passes strings through unchanged.""" | |
| from pyspark.inprocess.udf import inprocess_udf | |
| from pyspark.sql.types import StringType | |
| @inprocess_udf(returnType=StringType()) | |
| def identity(s): | |
| return s | |
| data = [("hello",), ("world",), (None,)] | |
| df = self.spark.createDataFrame(data, ["v"]) | |
| result = [r[0] for r in df.select(identity(df["v"])).collect()] | |
| self.assertEqual(result[0], "hello") | |
| self.assertEqual(result[1], "world") | |
| self.assertIsNone(result[2]) | |
| def test_string_upper(self): | |
| """@inprocess_udf with StringType applies utf8_upper transformation.""" | |
| import pyarrow.compute as pc | |
| from pyspark.inprocess.udf import inprocess_udf | |
| from pyspark.sql.types import StringType | |
| @inprocess_udf(returnType=StringType()) | |
| def upper(s): | |
| return pc.utf8_upper(s) | |
| data = [("hello",), ("world",)] | |
| df = self.spark.createDataFrame(data, ["v"]) | |
| result = [r[0] for r in df.select(upper(df["v"])).collect()] | |
| self.assertEqual(result, ["HELLO", "WORLD"]) | |
| # ------------------------------------------------------------------ | |
| # Binary type | |
| # ------------------------------------------------------------------ | |
| def test_binary_identity(self): | |
| """@inprocess_udf with BinaryType passes bytes through unchanged.""" | |
| from pyspark.inprocess.udf import inprocess_udf | |
| from pyspark.sql.types import BinaryType | |
| @inprocess_udf(returnType=BinaryType()) | |
| def identity(b): | |
| return b | |
| data = [(b"hello",), (b"world",), (None,)] | |
| df = self.spark.createDataFrame(data, ["v"]) | |
| result = [r[0] for r in df.select(identity(df["v"])).collect()] | |
| self.assertEqual(bytes(result[0]), b"hello") | |
| self.assertEqual(bytes(result[1]), b"world") | |
| self.assertIsNone(result[2]) | |
| # ------------------------------------------------------------------ | |
| # Array type | |
| # ------------------------------------------------------------------ | |
| def test_array_identity(self): | |
| """@inprocess_udf with ArrayType(LongType()) passes arrays through unchanged.""" | |
| from pyspark.inprocess.udf import inprocess_udf | |
| from pyspark.sql.types import ArrayType, LongType | |
| @inprocess_udf(returnType=ArrayType(LongType())) | |
| def identity(arr): | |
| return arr | |
| data = [([1, 2, 3],), ([4, 5],), (None,)] | |
| df = self.spark.createDataFrame(data, ["v"]) | |
| result = [r[0] for r in df.select(identity(df["v"])).collect()] | |
| self.assertEqual(list(result[0]), [1, 2, 3]) | |
| self.assertEqual(list(result[1]), [4, 5]) | |
| self.assertIsNone(result[2]) | |
| # ------------------------------------------------------------------ | |
| # Struct type | |
| # ------------------------------------------------------------------ | |
| def test_struct_identity(self): | |
| """@inprocess_udf with StructType passes structs through unchanged.""" | |
| from pyspark.inprocess.udf import inprocess_udf | |
| from pyspark.sql.types import DoubleType, LongType, StructField, StructType | |
| inner = StructType([StructField("a", LongType()), StructField("b", DoubleType())]) | |
| outer = StructType([StructField("v", inner)]) | |
| @inprocess_udf(returnType=inner) | |
| def identity(s): | |
| return s | |
| data = [((1, 2.0),), ((3, 4.0),)] | |
| df = self.spark.createDataFrame(data, outer) | |
| result = [r[0] for r in df.select(identity(df["v"])).collect()] | |
| self.assertEqual(result[0]["a"], 1) | |
| self.assertAlmostEqual(result[0]["b"], 2.0) | |
| self.assertEqual(result[1]["a"], 3) | |
| self.assertAlmostEqual(result[1]["b"], 4.0) | |
| # ------------------------------------------------------------------ | |
| # Date type | |
| # ------------------------------------------------------------------ | |
| def test_date_identity(self): | |
| """@inprocess_udf with DateType passes dates through unchanged.""" | |
| import datetime | |
| from pyspark.inprocess.udf import inprocess_udf | |
| from pyspark.sql.types import DateType | |
| @inprocess_udf(returnType=DateType()) | |
| def identity(d): | |
| return d | |
| dates = [datetime.date(2024, 1, 1), datetime.date(2024, 6, 15), None] | |
| data = [(d,) for d in dates] | |
| df = self.spark.createDataFrame(data, ["v"]) | |
| result = [r[0] for r in df.select(identity(df["v"])).collect()] | |
| self.assertEqual(result[0], datetime.date(2024, 1, 1)) | |
| self.assertEqual(result[1], datetime.date(2024, 6, 15)) | |
| self.assertIsNone(result[2]) | |
| # ------------------------------------------------------------------ | |
| # Timestamp type | |
| # ------------------------------------------------------------------ | |
| def test_timestamp_identity(self): | |
| """@inprocess_udf with TimestampType passes timestamps through unchanged.""" | |
| import datetime | |
| from pyspark.inprocess.udf import inprocess_udf | |
| from pyspark.sql.types import TimestampType | |
| @inprocess_udf(returnType=TimestampType()) | |
| def identity(ts): | |
| return ts | |
| data = [(datetime.datetime(2024, 3, 15, 10, 30, 0),), (None,)] | |
| df = self.spark.createDataFrame(data, ["v"]) | |
| result = [r[0] for r in df.select(identity(df["v"])).collect()] | |
| self.assertIsNone(result[1]) | |
| # Check date components are preserved (timezone handling may shift hours) | |
| self.assertEqual(result[0].year, 2024) | |
| self.assertEqual(result[0].month, 3) | |
| self.assertEqual(result[0].day, 15) | |
| # ------------------------------------------------------------------ | |
| # sitePackages / sys.path extension | |
| # ------------------------------------------------------------------ | |
| def test_site_packages_path_extension_works_in_interpreter(self): | |
| """The plugin's actual sitePackages config exposes a module to the interpreter.""" | |
| from pyspark.inprocess import inprocess_udf | |
| from pyspark.sql.types import LongType | |
| @inprocess_udf(LongType()) | |
| def read_magic(x): | |
| import _inprocess_test_helper | |
| import pyarrow as pa | |
| return pa.array([_inprocess_test_helper.MAGIC] * len(x), type=pa.int64()) | |
| df = self.spark.range(1) | |
| self.assertEqual(df.select(read_magic(df.id)).first()[0], 99) | |
| def test_bootstrap_converts_base_exceptions_and_retries_the_same_configuration(self): | |
| jvm = self.spark.sparkContext._jvm | |
| runtime = jvm.org.apache.spark.sql.execution.python.InProcessPythonRuntime | |
| runtime_module = getattr( | |
| getattr(jvm.org.apache.spark.sql.execution.python, "InProcessPythonRuntime$"), "MODULE$" | |
| ) | |
| def initialize(): | |
| paths = jvm.java.util.ArrayList() | |
| paths.add(self.site_packages) | |
| paths.add(str(self.jep_dir.parent)) | |
| runtime.initialize(jvm.PythonUtils.toSeq(paths)) | |
| # Check the shared guard independently of CPython/JEP exception handling. | |
| for script in [ | |
| "raise KeyboardInterrupt('bootstrap probe')", | |
| "import missing_bootstrap_probe", | |
| ]: | |
| with self.assertRaisesRegex(RuntimeError, "bootstrap failed"): | |
| exec(runtime_module.bootstrapScript(script), {}) | |
| runtime.shutdown() | |
| probe = Path(self.site_packages) / "probe.pth" | |
| try: | |
| probe.write_text("import sys; raise KeyboardInterrupt('bootstrap probe')\n") | |
| with self.assertRaisesRegex(Exception, "bootstrap failed.*bootstrap probe"): | |
| initialize() | |
| finally: | |
| probe.unlink() | |
| initialize() | |
| from pyspark.inprocess import inprocess_udf | |
| identity = inprocess_udf("long")(lambda x: x) | |
| self.assertEqual(self.spark.range(1).select(identity("id")).first()[0], 0) | |
| def test_runtime_restart_from_driver_thread(self): | |
| from pyspark.inprocess import inprocess_udf | |
| from pyspark.sql.types import LongType | |
| identity = inprocess_udf(LongType())(lambda x: x) | |
| df = self.spark.range(3) | |
| expected = df.select(identity(df.id)).collect() | |
| runtime = self.spark.sparkContext._jvm.org.apache.spark.sql.execution.python | |
| runtime.InProcessPythonRuntime.shutdown() | |
| try: | |
| with self.assertRaisesRegex(Exception, "has been stopped"): | |
| df.select(identity(df.id)).collect() | |
| changed = self.spark.sparkContext._jvm.java.util.ArrayList() | |
| changed.add(self.site_packages) | |
| with self.assertRaisesRegex(Exception, "Restart the executor process"): | |
| runtime.InProcessPythonRuntime.initialize( | |
| self.spark.sparkContext._jvm.PythonUtils.toSeq(changed) | |
| ) | |
| finally: | |
| paths = self.spark.sparkContext._jvm.java.util.ArrayList() | |
| paths.add(self.site_packages) | |
| paths.add(str(self.jep_dir.parent)) | |
| runtime.InProcessPythonRuntime.initialize( | |
| self.spark.sparkContext._jvm.PythonUtils.toSeq(paths) | |
| ) | |
| self.assertEqual(df.select(identity(df.id)).collect(), expected) | |
| # ------------------------------------------------------------------ | |
| # Error handling | |
| # ------------------------------------------------------------------ | |
| def test_buggy_udf_exposes_python_traceback(self): | |
| """A UDF that raises an exception must include the Python traceback in the error. | |
| The traceback must name the exception type, the error message, and the | |
| file/line where the exception was raised, matching what you would see in a | |
| standard Python traceback. | |
| """ | |
| from pyspark.inprocess.udf import inprocess_udf | |
| from pyspark.sql.types import LongType | |
| @inprocess_udf(returnType=LongType()) | |
| def always_fails(x): | |
| raise ValueError("intentional test error from always_fails") | |
| df = self.spark.range(1) | |
| try: | |
| df.select(always_fails(df["id"])).collect() | |
| self.fail("Expected exception was not raised") | |
| except Exception as e: | |
| error_msg = str(e) | |
| self.assertIn( | |
| "ValueError", error_msg, "Exception type must appear in the error message" | |
| ) | |
| self.assertIn( | |
| "intentional test error from always_fails", | |
| error_msg, | |
| "Exception message must appear in the error", | |
| ) | |
| self.assertIn( | |
| "always_fails", error_msg, "UDF function name must appear in the traceback" | |
| ) | |
| def test_sliced_results_are_read_correctly_by_arrow_java(self): | |
| import pyarrow as pa | |
| from pyspark.inprocess import inprocess_udf | |
| from pyspark.sql.types import ( | |
| ArrayType, | |
| BooleanType, | |
| LongType, | |
| StringType, | |
| StructField, | |
| StructType, | |
| ) | |
| cases = [ | |
| (pa.array([9, 1, None, 3]).slice(1), LongType()), | |
| (pa.array([False, True, None, False]).slice(1), BooleanType()), | |
| (pa.array(["discard", "one", None, "three"]).slice(1), StringType()), | |
| (pa.array([[9], [1], None, [3]]).slice(1), ArrayType(LongType())), | |
| ( | |
| pa.StructArray.from_arrays([pa.array([9, 1, None, 3]).slice(1)], names=["x"]), | |
| StructType([StructField("x", LongType())]), | |
| ), | |
| ] | |
| df = self.spark.range(3, numPartitions=1) | |
| for value, return_type in cases: | |
| with self.subTest(return_type=return_type): | |
| identity = inprocess_udf(return_type)(lambda x: value) | |
| actual = [r[0] for r in df.select(identity(df.id)).collect()] | |
| if isinstance(return_type, StructType): | |
| actual = [r.asDict() for r in actual] | |
| self.assertEqual(actual, value.to_pylist()) | |
| def test_retained_inputs_are_not_overwritten_by_later_batches(self): | |
| from pyspark.inprocess import inprocess_udf | |
| from pyspark.sql.types import LongType | |
| retained = [] | |
| @inprocess_udf(LongType(), deterministic=False) | |
| def remember(x): | |
| for previous, snapshot in retained: | |
| if previous.to_pylist() != snapshot: | |
| raise ValueError("retained input changed") | |
| retained.append((x, x.to_pylist())) | |
| return x | |
| df = self.spark.createDataFrame([(1,), (None,), (3,), (4,), (None,), (6,)], "x long") | |
| df = df.coalesce(1) | |
| with self.sql_conf({"spark.sql.execution.arrow.maxRecordsPerBatch": "2"}): | |
| self.assertEqual( | |
| [r[0] for r in df.select(remember("x")).collect()], [1, None, 3, 4, None, 6] | |
| ) | |
| def test_pass_through_struct_with_duplicate_names(self): | |
| from pyspark.inprocess import inprocess_udf | |
| from pyspark.sql import functions as F | |
| from pyspark.sql.types import LongType | |
| identity = inprocess_udf(LongType())(lambda x: x) | |
| df = self.spark.range(3).select( | |
| "id", F.struct(F.col("id"), F.col("id").cast("string").alias("id")).alias("s") | |
| ) | |
| # Materialize the struct so CollapseProject cannot move it above the UDF. | |
| df.cache() | |
| try: | |
| rows = df.select("s", identity("id")).collect() | |
| self.assertEqual( | |
| [(tuple(r[0]), r[1]) for r in rows], [((i, str(i)), i) for i in range(3)] | |
| ) | |
| finally: | |
| df.unpersist() | |
| def test_pass_through_interval_does_not_require_arrow_conversion(self): | |
| from pyspark.inprocess import inprocess_udf | |
| from pyspark.sql.types import LongType | |
| identity = inprocess_udf(LongType())(lambda x: x) | |
| df = self.spark.range(3).selectExpr( | |
| "id", "make_interval(0, 0, 0, 0, 0, 0, 10000000000 + id) AS c" | |
| ) | |
| df.cache() | |
| try: | |
| # CalendarInterval cannot be converted to a Python Row. Compare JVM Row values. | |
| expected = df.select("c", "id")._jdf.collect() | |
| actual = df.select("c", identity("id"))._jdf.collect() | |
| self.assertEqual( | |
| [(r.get(0).toString(), r.getLong(1)) for r in actual], | |
| [(r.get(0).toString(), r.getLong(1)) for r in expected], | |
| ) | |
| finally: | |
| df.unpersist() | |
| def test_mixed_worker_and_inprocess_udfs(self): | |
| import pyarrow.compute as pc | |
| from pyspark.inprocess import inprocess_udf | |
| from pyspark.sql.functions import udf | |
| from pyspark.sql.types import LongType | |
| double = inprocess_udf(LongType())(lambda x: pc.multiply(x, 2)) | |
| plus_one = udf(lambda x: x + 1, LongType(), useArrow=False) | |
| df = self.spark.range(4) | |
| rows = df.select(double(plus_one("id")), plus_one(double("id"))).collect() | |
| self.assertEqual([tuple(r) for r in rows], [(2 * (i + 1), 2 * i + 1) for i in range(4)]) | |
| def test_pipelined_arrow_worker_consumes_inprocess_results(self): | |
| import pyarrow.compute as pc | |
| from pyspark.inprocess import inprocess_udf | |
| from pyspark.sql.functions import arrow_udf | |
| conf = self.spark.sparkContext._jvm.org.apache.spark.SparkEnv.get().conf() | |
| key = "spark.python.udf.pipelined.enabled" | |
| previous = conf.get(key, "false") | |
| conf.set(key, "true") | |
| try: | |
| double = inprocess_udf("long")(lambda x: pc.multiply(x, 2)) | |
| triple = inprocess_udf("long")(lambda x: pc.multiply(x, 3)) | |
| add = arrow_udf(lambda x, y: pc.add(x, y), "long") | |
| with self.sql_conf({"spark.sql.execution.arrow.maxRecordsPerBatch": "2"}): | |
| query = self.spark.range(8, numPartitions=2).select(add(double("id"), triple("id"))) | |
| plan = query._jdf.queryExecution().executedPlan().toString() | |
| self.assertIn("InProcessArrowEvalPython", plan) | |
| self.assertIn("ArrowEvalPython", plan) | |
| self.assertEqual([r[0] for r in query.collect()], [i * 5 for i in range(8)]) | |
| finally: | |
| conf.set(key, previous) | |
| def test_pipelined_worker_reads_materialized_nested_results_across_batches(self): | |
| import pyarrow as pa | |
| import pyarrow.compute as pc | |
| from pyspark.inprocess import inprocess_udf | |
| from pyspark.sql.functions import arrow_udf | |
| conf = self.spark.sparkContext._jvm.org.apache.spark.SparkEnv.get().conf() | |
| key = "spark.python.udf.pipelined.enabled" | |
| previous = conf.get(key, "false") | |
| conf.set(key, "true") | |
| try: | |
| # The worker's writer thread consumes these rows; each must outlive its batch. | |
| pair = inprocess_udf("array<string>")( | |
| lambda s: pa.array([[v, v] for v in s.to_pylist()], pa.list_(pa.string())) | |
| ) | |
| joined = arrow_udf(lambda a: pc.binary_join(a, "-"), "string") | |
| with self.sql_conf({"spark.sql.execution.arrow.maxRecordsPerBatch": "3"}): | |
| df = self.spark.range(10, numPartitions=2).selectExpr("CAST(id AS STRING) AS s") | |
| rows = df.select(joined(pair("s"))).collect() | |
| self.assertEqual([r[0] for r in rows], [f"{i}-{i}" for i in range(10)]) | |
| # A pass-through column makes the in-process node buffer and join input rows. | |
| df = df.selectExpr("s", "CAST(s AS INT) AS id") | |
| rows = df.select("id", joined(pair("s"))).collect() | |
| self.assertEqual([tuple(r) for r in rows], [(i, f"{i}-{i}") for i in range(10)]) | |
| finally: | |
| conf.set(key, previous) | |
| def test_pipelined_worker_stopping_early_does_not_break_inprocess_input(self): | |
| import pyarrow.compute as pc | |
| from pyspark.inprocess import inprocess_udf | |
| from pyspark.sql.functions import arrow_udf | |
| conf = self.spark.sparkContext._jvm.org.apache.spark.SparkEnv.get().conf() | |
| key = "spark.python.udf.pipelined.enabled" | |
| previous = conf.get(key, "false") | |
| conf.set(key, "true") | |
| try: | |
| double = inprocess_udf("long")(lambda x: pc.multiply(x, 2)) | |
| plus_one = arrow_udf(lambda x: pc.add(x, 1), "long") | |
| with self.sql_conf({"spark.sql.execution.arrow.maxRecordsPerBatch": "100"}): | |
| # The task completes after a few rows while the writer thread still pulls input. | |
| # A filter on the UDF result keeps the limit from being pushed below the UDFs. | |
| df = self.spark.range(0, 100000, 1, 2).selectExpr("id", "id % 7 AS k") | |
| # A coalesced parent evaluates the in-process node on the writer thread. | |
| coalesced = df.select(double("id").alias("a"), "k").coalesce(1) | |
| queries = [ | |
| df.select("k", plus_one(double("id")).alias("v")).filter("v > 0").limit(1), | |
| coalesced.select(plus_one("a").alias("v"), "k").filter("v > 0").limit(10), | |
| ] | |
| for query in queries: | |
| plan = query._jdf.queryExecution().executedPlan().toString() | |
| self.assertNotIn("Limit", plan[plan.index("ArrowEvalPython") :]) | |
| for _ in range(5): | |
| self.assertEqual(len(queries[0].collect()), 1) | |
| self.assertEqual(len(queries[1].collect()), 10) | |
| # Later tasks in the same executor still run correctly. | |
| total = df.select(plus_one(double("id")).alias("v")).groupBy().sum("v") | |
| self.assertEqual(total.first()[0], sum(2 * i + 1 for i in range(100000))) | |
| finally: | |
| conf.set(key, previous) | |
| def test_computed_and_reordered_arguments_with_pass_through_columns(self): | |
| import pyarrow.compute as pc | |
| from pyspark.inprocess import inprocess_udf | |
| concat = inprocess_udf("string")(lambda a, b: pc.binary_join_element_wise(a, b, ":")) | |
| with self.sql_conf({"spark.sql.execution.arrow.maxRecordsPerBatch": "3"}): | |
| df = self.spark.range(10, numPartitions=2).selectExpr( | |
| "id", "CAST(id AS STRING) AS s", "repeat('x', CAST(id AS INT)) AS pad" | |
| ) | |
| # Computed and reordered arguments, while id and pad pass through unchanged. | |
| rows = df.select("id", "pad", concat(df.pad, df.s.substr(1, 1))).collect() | |
| self.assertEqual( | |
| [tuple(r) for r in rows], [(i, "x" * i, "x" * i + ":" + str(i)[0]) for i in range(10)] | |
| ) | |
| def test_input_columns_read_back_from_arrow_are_unchanged(self): | |
| import pyarrow as pa | |
| from pyspark.inprocess import inprocess_udf | |
| # Every input column is an argument of a type that reads back exactly, so the output's | |
| # input columns come from Arrow. | |
| count = inprocess_udf("long")(lambda *cols: pa.array([len(cols)] * len(cols[0]))) | |
| with self.sql_conf({"spark.sql.execution.arrow.maxRecordsPerBatch": "3"}): | |
| df = self.spark.range(10, numPartitions=2).selectExpr( | |
| "IF(id % 4 = 0, NULL, CAST(id AS STRING)) AS s", | |
| "IF(id = 3, CAST(X'FF' AS STRING), 'ok') AS invalid_utf8", | |
| "TIMESTAMP_MICROS(id * 1000003) AS ts", | |
| "CAST(TIMESTAMP_MICROS(id) AS TIMESTAMP_NTZ) AS ntz", | |
| "NAMED_STRUCT('a', id, 'b', id / 3, 'c', BINARY(CAST(id AS STRING))) AS st", | |
| ) | |
| factory = self.spark._jvm.org.apache.spark.sql.execution.python | |
| reads_back = factory.InProcessArrowEvalPythonEvaluatorFactory.readsBack | |
| from_json = self.spark._jvm.org.apache.spark.sql.types.DataType.fromJson | |
| for field in df.schema.fields: | |
| self.assertTrue(reads_back(from_json(field.dataType.json())), field) | |
| expected = df.collect() | |
| query = df.select("*", count(*[df[c] for c in df.columns]).alias("n")) | |
| # ReadBack also needs the arguments to be exactly the child's output, in order. | |
| nodes = [query._jdf.queryExecution().executedPlan()] | |
| evaluators = [] | |
| while nodes: | |
| node = nodes.pop() | |
| if node.nodeName() == "InProcessArrowEvalPython": | |
| evaluators.append(node) | |
| children = node.children().iterator() | |
| while children.hasNext(): | |
| nodes.append(children.next()) | |
| self.assertEqual(len(evaluators), 1) | |
| node = evaluators[0] | |
| arguments = node.udfs().apply(0).children() | |
| output = node.child().output() | |
| self.assertEqual(arguments.size(), output.size()) | |
| for i in range(output.size()): | |
| self.assertTrue(arguments.apply(i).semanticEquals(output.apply(i)), i) | |
| rows = query.collect() | |
| self.assertEqual([r[:-1] for r in rows], [tuple(r) for r in expected]) | |
| self.assertEqual({r.n for r in rows}, {len(df.columns)}) | |
| def test_input_columns_without_exact_arrow_read_back_keep_original_rows(self): | |
| import pyarrow as pa | |
| from pyspark.inprocess import inprocess_udf | |
| # Intervals are converted for Arrow, and decimals, arrays and maps would read back only | |
| # element by element, so these rows are buffered rather than read back. | |
| count = inprocess_udf("long")(lambda *cols: pa.array([len(cols)] * len(cols[0]))) | |
| with self.sql_conf({"spark.sql.execution.arrow.maxRecordsPerBatch": "3"}): | |
| df = self.spark.range(10, numPartitions=2).selectExpr( | |
| "id", | |
| "MAKE_INTERVAL(0, 1, 0, CAST(id AS INT), 0, 0, id / 1000) AS i", | |
| "CAST(id AS DECIMAL(20, 3)) / 7 AS d", | |
| "IF(id = 5, NULL, ARRAY(CAST(id AS STRING), NULL)) AS arr", | |
| "MAP('k', IF(id % 2 = 0, NULL, id)) AS m", | |
| ) | |
| # PySpark cannot collect intervals, so compare them as strings. | |
| columns = ["id", "CAST(i AS STRING)", "d", "arr", "m"] | |
| expected = df.selectExpr(*columns).collect() | |
| result = df.select("*", count(*[df[c] for c in df.columns]).alias("n")) | |
| rows = result.selectExpr(*columns, "n").collect() | |
| self.assertEqual([r[:-1] for r in rows], [tuple(r) for r in expected]) | |
| self.assertEqual({r.n for r in rows}, {len(df.columns)}) | |
| def test_full_validation_can_be_disabled(self): | |
| import pyarrow.compute as pc | |
| from pyspark.inprocess import inprocess_udf | |
| upper = inprocess_udf("string")(lambda s: pc.utf8_upper(s)) | |
| key = "spark.sql.execution.pythonUDF.inProcess.fullValidation.enabled" | |
| for enabled in ["true", "false"]: | |
| with self.sql_conf({key: enabled}): | |
| rows = self.spark.range(3).selectExpr("CAST(id AS STRING) || 'a' AS s") | |
| self.assertEqual( | |
| [r[0] for r in rows.select(upper("s")).collect()], ["0A", "1A", "2A"] | |
| ) | |
| def test_duplicate_names_in_udf_arguments_are_rejected(self): | |
| import pyarrow as pa | |
| from pyspark.inprocess import inprocess_udf | |
| from pyspark.sql import functions as F | |
| from pyspark.sql.types import LongType | |
| size = inprocess_udf(LongType())(lambda x: pa.array([len(x)] * len(x), type=pa.int64())) | |
| df = self.spark.range(3) | |
| duplicate = F.struct(F.col("id"), F.col("id").cast("string").alias("id")) | |
| with self.assertRaisesRegex(Exception, "DUPLICATED_FIELD_NAME"): | |
| df.select(size(duplicate)).collect() | |
| def test_grouping_aggregate_results_and_nested_calls(self): | |
| import pyarrow.compute as pc | |
| from pyspark.inprocess import inprocess_udf | |
| from pyspark.sql import functions as F | |
| from pyspark.sql.types import LongType | |
| double = inprocess_udf(LongType())(lambda x: pc.multiply(x, 2)) | |
| df = self.spark.range(4).selectExpr("id % 2 AS k", "id AS v") | |
| grouped = df.groupBy("k").agg(double("k").alias("r")) | |
| self.assertEqual(sorted(r.r for r in grouped.collect()), [0, 2]) | |
| summed = df.groupBy("k").agg(F.sum("v").alias("s")).select(double("s")) | |
| self.assertEqual(sorted(r[0] for r in summed.collect()), [4, 8]) | |
| constants = df.agg(F.count("*"), double(F.lit(1))) | |
| self.assertEqual(tuple(constants.first()), (4, 2)) | |
| repeated = df.groupBy(double("k")).agg(double("k")) | |
| self.assertEqual(sorted(tuple(r) for r in repeated.collect()), [(0, 0), (2, 2)]) | |
| nested = df.select(double("v").alias("x")).select(double("x")) | |
| self.assertEqual(sorted(r[0] for r in nested.collect()), [0, 4, 8, 12]) | |
| def test_nondeterministic_grouping_and_ordering(self): | |
| from pyspark.inprocess import inprocess_udf | |
| from pyspark.sql.types import LongType | |
| identity = inprocess_udf(LongType(), deterministic=False)(lambda x: x) | |
| df = self.spark.range(4) | |
| self.assertEqual(len(df.groupBy(identity("id")).count().collect()), 4) | |
| self.assertEqual([r.id for r in df.orderBy(identity("id")).collect()], list(range(4))) | |
| def test_non_udf_predicates_run_before_udf(self): | |
| import pyarrow.compute as pc | |
| from pyspark.inprocess import inprocess_udf | |
| from pyspark.sql import functions as F | |
| from pyspark.sql.types import LongType | |
| divide = inprocess_udf(LongType())(lambda n, d: pc.divide(n, d)) | |
| df = self.spark.createDataFrame([(4, 0), (4, 2)], "n long, d long") | |
| result = df.filter(F.col("d") != 0).filter(divide("n", "d") > 1).collect() | |
| self.assertEqual([tuple(r) for r in result], [(4, 2)]) | |
| def test_partition_filters_do_not_evaluate_udfs_on_driver(self): | |
| from pyspark.inprocess import inprocess_udf | |
| from pyspark.sql import functions as F | |
| from pyspark.sql.types import LongType | |
| identity = inprocess_udf(LongType())(lambda x: x) | |
| with tempfile.TemporaryDirectory() as path: | |
| self.spark.range(4).selectExpr("id", "id % 2 AS k").write.partitionBy("k").parquet( | |
| path, mode="overwrite" | |
| ) | |
| df = self.spark.read.parquet(path) | |
| partition_identity = inprocess_udf(df.schema["k"].dataType)(lambda x: x) | |
| self.assertEqual( | |
| sorted(r.id for r in df.filter(partition_identity("k") == 1).collect()), [1, 3] | |
| ) | |
| filtered = df.filter((F.col("k") == 1) & (identity("id") > 1)) | |
| self.assertEqual([r.id for r in filtered.collect()], [3]) | |
| plan = filtered._jdf.queryExecution().executedPlan().toString() | |
| self.assertRegex(plan, r"PartitionFilters: \[[^\]]*k#") | |
| def test_nested_nullability_is_compatible(self): | |
| from pyspark.inprocess import inprocess_udf | |
| from pyspark.sql import functions as F | |
| from pyspark.sql.types import ArrayType, StringType | |
| identity = inprocess_udf(ArrayType(StringType()))(lambda x: x) | |
| df = self.spark.createDataFrame([("a,b",), (None,)], "s string") | |
| self.assertEqual( | |
| [r[0] for r in df.select(identity(F.split("s", ","))).collect()], [["a", "b"], None] | |
| ) | |
| def test_captured_spark_broadcasts_and_accumulators_are_rejected(self): | |
| from pyspark.inprocess import inprocess_udf | |
| from pyspark.sql.types import LongType | |
| broadcast = self.spark.sparkContext.broadcast(1) | |
| accumulator = self.spark.sparkContext.accumulator(0) | |
| try: | |
| for value in (broadcast, accumulator): | |
| with self.subTest(value=type(value).__name__): | |
| with self.assertRaisesRegex(NotImplementedError, "broadcasts or accumulators"): | |
| inprocess_udf(LongType())(lambda x: value.value)("id") | |
| finally: | |
| broadcast.destroy() | |
| def test_sql_registration_fails_early(self): | |
| from pyspark.inprocess import inprocess_udf | |
| from pyspark.sql.types import LongType | |
| identity = inprocess_udf(LongType())(lambda x: x) | |
| with self.assertRaisesRegex(TypeError, "INVALID_UDF_EVAL_TYPE"): | |
| self.spark.udf.register("inprocess_identity", identity) | |
| def test_named_arguments_partition_evaluator_and_columnar_child(self): | |
| import pyarrow.compute as pc | |
| from pyspark.inprocess import inprocess_udf | |
| from pyspark.sql.types import LongType | |
| subtract = inprocess_udf(LongType())(lambda x, y: pc.subtract(x, y)) | |
| df = self.spark.range(8).selectExpr("id", "id + 1 as other").cache() | |
| try: | |
| df.count() | |
| for evaluator in ("true", "false"): | |
| with self.sql_conf( | |
| { | |
| "spark.sql.execution.usePartitionEvaluator": evaluator, | |
| "spark.sql.execution.arrow.pythonUDF.columnarInput.enabled": "true", | |
| "spark.sql.adaptive.enabled": "false", | |
| } | |
| ): | |
| query = df.select(subtract(y="id", x="other")) | |
| self.assertEqual([r[0] for r in query.collect()], [1] * 8) | |
| plan = query._jdf.queryExecution().executedPlan().toString() | |
| self.assertIn("InProcessArrowEvalPython", plan) | |
| nodes = [query._jdf.queryExecution().executedPlan()] | |
| metrics = None | |
| while nodes: | |
| node = nodes.pop() | |
| if node.nodeName() == "InProcessArrowEvalPython": | |
| # A dual-mode cache scan can execute rows directly without a transition. | |
| self.assertTrue(node.child().supportsColumnar()) | |
| self.assertFalse(node.supportsColumnar()) | |
| metrics = node.metrics() | |
| children = node.children().iterator() | |
| while children.hasNext(): | |
| nodes.append(children.next()) | |
| self.assertIsNotNone(metrics) | |
| self.assertEqual(metrics.apply("pythonNumRowsReceived").value(), 8) | |
| self.assertGreater(metrics.apply("pythonDataSent").value(), 0) | |
| self.assertGreater(metrics.apply("pythonDataReceived").value(), 0) | |
| # In nanoseconds, so a sub-millisecond registration or batch still counts. | |
| processing = metrics.apply("pythonProcessingTime").value() | |
| self.assertGreater(metrics.apply("pythonInitTime").value(), 0) | |
| self.assertGreater(processing, 0) | |
| self.assertGreaterEqual(metrics.apply("pythonTotalTime").value(), processing) | |
| finally: | |
| df.unpersist() | |
| def test_nonroot_limit_and_offset_preserve_order(self): | |
| from pyspark.inprocess import inprocess_udf | |
| from pyspark.sql.types import LongType | |
| identity = inprocess_udf(LongType())(lambda x: x) | |
| with self.sql_conf( | |
| { | |
| "spark.sql.execution.topKSortFallbackThreshold": "1", | |
| "spark.sql.shuffle.partitions": "4", | |
| "spark.sql.adaptive.enabled": "false", | |
| } | |
| ): | |
| df = self.spark.range(100, numPartitions=4).orderBy("id") | |
| mapped = df.select(identity("id").alias("v")) | |
| for offset in (0, 7): | |
| query = mapped.offset(offset).limit(10).distinct() | |
| self.assertEqual( | |
| sorted(r.v for r in query.collect()), list(range(offset, offset + 10)) | |
| ) | |
| self.assertIn("Sort [id#", query._jdf.queryExecution().executedPlan().toString()) | |
| def test_map_entries_offset_and_custom_names(self): | |
| import pyarrow as pa | |
| from pyspark.inprocess import inprocess_udf | |
| from pyspark.sql.types import LongType, MapType, StringType | |
| def make_map(x): | |
| map_type = pa.map_(pa.field("k", pa.string(), False), pa.field("v", pa.int64())) | |
| entries = pa.StructArray.from_arrays( | |
| [pa.array(["hidden", "a", "b", "c"]), pa.array([None, 1, 2, 3])], | |
| fields=[map_type.key_field, map_type.item_field], | |
| ) | |
| offsets = pa.array([0, 1, 3], type=pa.int32()) | |
| return pa.Array.from_buffers( | |
| map_type, 2, [None, offsets.buffers()[1]], children=[entries.slice(1)] | |
| ) | |
| udf = inprocess_udf(MapType(StringType(), LongType()))(make_map) | |
| rows = self.spark.range(2, numPartitions=1).select(udf("id")).collect() | |
| self.assertEqual([r[0] for r in rows], [{"a": 1}, {"b": 2, "c": 3}]) | |
| def test_hidden_struct_nulls(self): | |
| import pyarrow as pa | |
| import pyarrow.compute as pc | |
| from pyspark.inprocess import inprocess_udf | |
| from pyspark.sql.types import IntegerType, StructField, StructType | |
| @inprocess_udf(StructType([StructField("len", IntegerType(), False)])) | |
| def lengths(s): | |
| return pa.StructArray.from_arrays( | |
| [pc.utf8_length(s)], names=["len"], mask=pc.is_null(s) | |
| ) | |
| df = self.spark.createDataFrame([(None,), ("a",)], "s string") | |
| self.assertEqual([r[0] for r in df.select(lengths("s")).collect()], [None, (1,)]) | |
| def test_large_closure_uses_bulk_transfer(self): | |
| import pyarrow as pa | |
| from pyspark.inprocess import inprocess_udf | |
| from pyspark.sql.types import LongType | |
| model = bytes(range(256)) * 16384 | |
| @inprocess_udf(LongType()) | |
| def lookup(x): | |
| return pa.array([model[i % len(model)] for i in x.to_pylist()], type=pa.int64()) | |
| df = self.spark.range(256, numPartitions=4) | |
| self.assertEqual([r[0] for r in df.select(lookup("id")).collect()], list(range(256))) | |
| if __name__ == "__main__": | |
| from pyspark.testing import main | |
| main() |