blob: 4367f6b959d976304d11f864509d4538e3d160af [file]
#
# 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.
#
import datetime
import unittest
from pyspark.sql.functions import col, udf
from pyspark.sql import Row
from pyspark.testing.sqlutils import ReusedSQLTestCase
from pyspark.testing.utils import (
assertDataFrameEqual,
have_pandas,
have_pyarrow,
pandas_requirement_message,
pyarrow_requirement_message,
)
@unittest.skipIf(
not have_pandas or not have_pyarrow,
pandas_requirement_message or pyarrow_requirement_message, # type: ignore[arg-type]
)
class ArrowPythonUDFCachedInputTests(ReusedSQLTestCase):
"""
Arrow-optimized Python UDFs reading columnar input from an Arrow-cached relation.
The Arrow-cached scan is a columnar Arrow-backed source, so it drives the columnar Arrow
Python runner (ColumnarArrowPythonWithNamedArgumentRunner) end to end -- the path an
Arrow-backed DSv2 source would also take. This pins the init-message protocol between that
runner and the worker: a mismatched section there desyncs the worker's init parse and hangs
the task (SPARK-58241). It also pins the columnar fast path's shape gate: the cached vectors
are forwarded verbatim only when their physical shape matches what the stream's schema
declares, and fall back to row-based re-encoding otherwise.
This suite runs in its own module because spark.sql.cache.serializer is a static conf latched
into a JVM-wide singleton on first cache materialization.
"""
@classmethod
def conf(cls):
conf = super().conf()
# Static conf: must be set before the session is created.
conf.set(
"spark.sql.cache.serializer",
"org.apache.spark.sql.execution.columnar.ArrowCachedBatchSerializer",
)
return conf
@classmethod
def setUpClass(cls):
super().setUpClass()
cls.spark.conf.set("spark.sql.execution.pythonUDF.arrow.enabled", "true")
@classmethod
def tearDownClass(cls):
try:
cls.spark.conf.unset("spark.sql.execution.pythonUDF.arrow.enabled")
finally:
super().tearDownClass()
def _assert_columnar_arrow_eval(self, df, expected_nodes):
# Assert the executed plan contains exactly `expected_nodes` ArrowEvalPythonExec
# operators and every one of them takes the columnar input path -- otherwise the
# test silently stops exercising the code this suite exists to pin.
nodes = []
stack = [df._jdf.queryExecution().executedPlan()]
while stack:
node = stack.pop()
name = node.getClass().getSimpleName()
if name == "AdaptiveSparkPlanExec":
stack.append(node.executedPlan())
continue
if name == "ArrowEvalPythonExec":
nodes.append(node)
children = node.children()
for i in range(children.size()):
stack.append(children.apply(i))
self.assertEqual(len(nodes), expected_nodes, "unexpected number of ArrowEvalPythonExec")
for node in nodes:
self.assertTrue(node.supportsColumnar(), "expected the columnar input path")
def _assert_udf_on_cached_strings(self):
df = self.spark.createDataFrame(
[("hello",), (None,), ("world",)], schema="v string"
).cache()
try:
result = df.select(udf(lambda x: x.upper() if x is not None else None)(col("v")))
self._assert_columnar_arrow_eval(result, expected_nodes=1)
assertDataFrameEqual(result, [Row("HELLO"), Row(None), Row("WORLD")])
finally:
df.unpersist()
def test_udf_on_cached_strings(self):
# Cache-scan vectors use standard 32-bit var-width offsets, matching the default
# interchange schema: the columnar fast path forwards them verbatim.
self._assert_udf_on_cached_strings()
def test_udf_on_cached_strings_with_large_var_types(self):
# With useLargeVarTypes=true the UDF stream schema declares LargeUtf8 (64-bit offsets)
# but the cache always stores standard Utf8, so forwarding the cache-scan vector
# verbatim would produce a stream whose header and body disagree. The shape gate must
# route this through the row-based re-encoding fallback and still produce correct
# results.
with self.sql_conf({"spark.sql.execution.arrow.useLargeVarTypes": "true"}):
self._assert_udf_on_cached_strings()
def test_udf_on_cached_numeric_column_alongside_unselected_string(self):
# Only the UDF's input columns are serialized to the worker; the column that is not a
# UDF input rides through the pass-through recombination, so select it alongside the
# UDF result to observe that its values line up with the corresponding rows.
df = self.spark.createDataFrame([(1, "a"), (2, "b")], schema="i long, s string").cache()
try:
result = df.select(col("s"), udf(lambda x: x + 1, "long")(col("i")))
self._assert_columnar_arrow_eval(result, expected_nodes=1)
assertDataFrameEqual(result, [Row("a", 2), Row("b", 3)])
finally:
df.unpersist()
def test_udf_on_cached_map_column(self):
# Canonical map vectors from the cache pass the gate (their entry-struct children carry
# the canonical "key"/"value" names in order) and ride the columnar path verbatim. The
# rejection of a source whose entry children are swapped is pinned at the unit level
# (ArrowUtilsSuite), since no in-tree producer can build such a vector.
df = self.spark.createDataFrame(
[({"a": 1},), ({"b": 2, "c": 3},)], schema="m map<string,long>"
).cache()
try:
result = df.select(udf(lambda m: len(m), "long")(col("m")))
self._assert_columnar_arrow_eval(result, expected_nodes=1)
assertDataFrameEqual(result, [Row(1), Row(2)])
finally:
df.unpersist()
def test_udf_on_timestamps_cached_under_different_time_zone(self):
# Pins the timestamp pass-through end to end: the plan takes the columnar path and
# the stored epoch round-trips unchanged across a session-timezone change between
# cache materialization and the UDF query. Note the cache rebuilds its Arrow schema
# at read time with the query's session timezone, so the actual and declared timezone
# labels the gate compares are equal by construction here; the layout-equal
# label-mismatch case (e.g. an external DSv2 Arrow source labeling UTC while the
# stream schema declares the session timezone) has no in-tree columnar producer and
# is pinned by the ArrowUtilsSuite congruence tests instead.
ts = datetime.datetime(2025, 1, 6, 12, 30, 45)
with self.sql_conf({"spark.sql.session.timeZone": "UTC"}):
df = self.spark.createDataFrame([(ts,)], schema="ts timestamp").cache()
df.count() # materialize: the cached vectors are labeled with the UTC session tz
try:
with self.sql_conf({"spark.sql.session.timeZone": "America/Los_Angeles"}):
result = df.select(udf(lambda t: t, "timestamp")(col("ts")))
self._assert_columnar_arrow_eval(result, expected_nodes=1)
# The stored epoch round-trips; naive-datetime wall time is preserved because
# createDataFrame and collect both convert against the machine-local timezone.
assertDataFrameEqual(result, [Row(ts)])
finally:
df.unpersist()
if __name__ == "__main__":
from pyspark.testing import main
main()