This is an automated email from the ASF dual-hosted git repository.

viirya pushed a commit to branch branch-4.x
in repository https://gitbox.apache.org/repos/asf/spark.git


The following commit(s) were added to refs/heads/branch-4.x by this push:
     new a84fcaae64d0 [SPARK-58241][PYTHON] Fix the columnar Arrow Python UDF 
runner writing the input schema in the legacy protocol position
a84fcaae64d0 is described below

commit a84fcaae64d031cd68341805eff2d9193df1c79f
Author: Liang-Chi Hsieh <[email protected]>
AuthorDate: Tue Jul 21 17:55:11 2026 -0700

    [SPARK-58241][PYTHON] Fix the columnar Arrow Python UDF runner writing the 
input schema in the legacy protocol position
    
    ### What changes were proposed in this pull request?
    
    Two fixes to the Python worker init-message protocol:
    
    1. **Protocol fix**: `ColumnarArrowPythonWithNamedArgumentRunner` (the 
columnar-input Arrow Python UDF runner) still wrote `schema.json` as a UTF 
string at the head of the UDF command section for `SQL_ARROW_BATCHED_UDF`. The 
message-based worker protocol moved that schema into `evalConf` under the 
`"input_type"` key -- which the row-based `ArrowPythonWithNamedArgumentRunner` 
already does -- and the worker's `WorkerInitInfo.from_stream` therefore parses 
the command section as a UDF cou [...]
    
    2. **Fail-fast hardening**: the worker blocked *forever* instead of failing 
because `SparkSocketMessageReceiver._do_get_init_message` materializes the 
entire init message but never marked the resulting `ZeroCopyByteStream` 
finished, so an over-read waits on a condition for a chunk that can never 
arrive (the worker is single-threaded at that point). The receiver now calls 
`finish()` on the fully-materialized stream, so any future writer/reader 
protocol mismatch surfaces as an immediate [...]
    
    ### Why are the changes needed?
    
    Any Python UDF whose input plan produces columnar Arrow batches deadlocks: 
the JVM task thread waits for worker output, the worker waits for init-message 
bytes that were never sent, forever. This was latent since the columnar-input 
runner was added because no in-tree source produced Arrow-backed 
`ColumnarBatch` input for it; the Arrow-cached scan (SPARK-57268) is the first, 
so a simple `df.cache()` (with the Arrow cache serializer) followed by any 
Arrow-optimized Python UDF now hangs  [...]
    
    Diagnosed by instrumenting `WorkerInitInfo.from_stream`: the parse is 
byte-exact through the conf sections and desyncs precisely at the UDF command 
section, matching the extra `schema.json` write; the worker's `faulthandler` 
stack shows it blocked in `ZeroCopyByteStream._try_read_bytes` under 
`UDFInfo.from_stream`.
    
    ### Does this PR introduce _any_ user-facing change?
    
    No. It fixes a hang; the wire format for the columnar runner now matches 
what the worker has expected all along.
    
    ### How was this patch tested?
    
    - New e2e suite `pyspark.sql.tests.arrow.test_arrow_python_udf_cached` (own 
module because `spark.sql.cache.serializer` is a static conf latched into a 
JVM-wide singleton): Arrow-optimized Python UDFs over an Arrow-cached relation 
-- the exact deadlock scenario -- including an unselected pass-through column. 
Hung forever before the fix; passes in seconds with it.
    - New unit tests in `pyspark.tests.test_spark_message_receiver` for the 
socket receiver: declared content reads back, and an over-read past the 
declared init-message length raises `EOFError` instead of blocking.
    - Existing `pyspark.tests.test_zero_copy_byte_stream`, 
`pyspark.tests.test_spark_message_receiver`, and 
`pyspark.sql.tests.arrow.test_arrow_python_udf` all pass.
    
    ### Was this patch authored or co-authored using generative AI tooling?
    
    Generated-by: Claude Code
    
    This pull request and its description were written by Claude Code.
    
    Closes #57403 from viirya/arrow-columnar-udf-init-protocol.
    
    Authored-by: Liang-Chi Hsieh <[email protected]>
    Signed-off-by: Liang-Chi Hsieh <[email protected]>
    (cherry picked from commit e6d66abda0ac420f9f8448b6b10e6efbb3c67fbd)
    Signed-off-by: Liang-Chi Hsieh <[email protected]>
---
 dev/sparktestsupport/modules.py                    |  1 +
 .../socket/spark_socket_message_receiver.py        |  7 +-
 .../tests/arrow/test_arrow_python_udf_cached.py    | 97 ++++++++++++++++++++++
 .../pyspark/tests/test_spark_message_receiver.py   | 30 +++++++
 .../python/ColumnarArrowPythonRunner.scala         | 16 +++-
 5 files changed, 148 insertions(+), 3 deletions(-)

diff --git a/dev/sparktestsupport/modules.py b/dev/sparktestsupport/modules.py
index fdd2f2fa598f..3edebcee6537 100644
--- a/dev/sparktestsupport/modules.py
+++ b/dev/sparktestsupport/modules.py
@@ -593,6 +593,7 @@ pyspark_sql = Module(
         "pyspark.sql.tests.arrow.test_arrow_cogrouped_map_misc",
         "pyspark.sql.tests.arrow.test_arrow_grouped_map",
         "pyspark.sql.tests.arrow.test_arrow_python_udf",
+        "pyspark.sql.tests.arrow.test_arrow_python_udf_cached",
         "pyspark.sql.tests.arrow.test_arrow_udf",
         "pyspark.sql.tests.arrow.test_arrow_udf_grouped_agg",
         "pyspark.sql.tests.arrow.test_arrow_udf_scalar",
diff --git a/python/pyspark/messages/socket/spark_socket_message_receiver.py 
b/python/pyspark/messages/socket/spark_socket_message_receiver.py
index de3db2fe6611..7099f0207c1f 100644
--- a/python/pyspark/messages/socket/spark_socket_message_receiver.py
+++ b/python/pyspark/messages/socket/spark_socket_message_receiver.py
@@ -43,7 +43,12 @@ class SparkSocketMessageReceiver(SparkMessageReceiver):
         message_length = read_int(self._infile)
         message_content = self._infile.read(message_length)
 
-        return ZeroCopyByteStream(memoryview(message_content))
+        # The init message is fully materialized, so mark the stream finished: 
a parse that
+        # runs past the declared message length (e.g. a writer/reader protocol 
mismatch) must
+        # fail fast with EOFError instead of blocking forever on a chunk that 
will never come.
+        stream = ZeroCopyByteStream(memoryview(message_content))
+        stream.finish()
+        return stream
 
     def _do_get_data_stream(self) -> BinaryIO:
         # For socket communication, we just pass along the underlying socket
diff --git a/python/pyspark/sql/tests/arrow/test_arrow_python_udf_cached.py 
b/python/pyspark/sql/tests/arrow/test_arrow_python_udf_cached.py
new file mode 100644
index 000000000000..962302730e69
--- /dev/null
+++ b/python/pyspark/sql/tests/arrow/test_arrow_python_udf_cached.py
@@ -0,0 +1,97 @@
+#
+# 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 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,
+)
+
+
[email protected](
+    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).
+
+    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 test_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")))
+            assertDataFrameEqual(result, [Row("HELLO"), Row(None), 
Row("WORLD")])
+        finally:
+            df.unpersist()
+
+    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")))
+            assertDataFrameEqual(result, [Row("a", 2), Row("b", 3)])
+        finally:
+            df.unpersist()
+
+
+if __name__ == "__main__":
+    from pyspark.testing import main
+
+    main()
diff --git a/python/pyspark/tests/test_spark_message_receiver.py 
b/python/pyspark/tests/test_spark_message_receiver.py
index 398bae922e5b..944a5084546b 100644
--- a/python/pyspark/tests/test_spark_message_receiver.py
+++ b/python/pyspark/tests/test_spark_message_receiver.py
@@ -18,8 +18,10 @@ import io
 import unittest
 from typing import BinaryIO
 
+from pyspark.messages.socket.spark_socket_message_receiver import 
SparkSocketMessageReceiver
 from pyspark.messages.spark_message_receiver import SparkMessageReceiver
 from pyspark.messages.zero_copy_byte_stream import ZeroCopyByteStream
+from pyspark.serializers import SpecialLengths
 
 
 class StubMessageReceiver(SparkMessageReceiver):
@@ -73,6 +75,34 @@ class SparkMessageReceiverTests(unittest.TestCase):
                     getattr(receiver, invalid_call)()
 
 
+class SparkSocketMessageReceiverTests(unittest.TestCase):
+    """Tests for the socket-framed receiver."""
+
+    def _framed_init(self, content: bytes) -> io.BytesIO:
+        frame = io.BytesIO()
+        frame.write(SpecialLengths.START_OF_INIT_MESSAGE.to_bytes(4, "big", 
signed=True))
+        frame.write(len(content).to_bytes(4, "big", signed=True))
+        frame.write(content)
+        frame.seek(0)
+        return frame
+
+    def test_init_message_reads_declared_content(self):
+        receiver = SparkSocketMessageReceiver(self._framed_init(b"abcdef"))
+        stream = receiver.get_init_message()
+        self.assertEqual(bytes(stream.read(6)), b"abcdef")
+
+    def test_init_message_over_read_raises_eof_instead_of_blocking(self):
+        # The init message is fully materialized, so the stream must be marked 
finished:
+        # a parse that runs past the declared length (e.g. a writer/reader 
protocol
+        # mismatch, see SPARK-58241) must fail fast with EOFError -- with an 
unfinished
+        # stream it would block forever waiting for a chunk that never arrives.
+        receiver = SparkSocketMessageReceiver(self._framed_init(b"abcdef"))
+        stream = receiver.get_init_message()
+        stream.read(4)
+        with self.assertRaises(EOFError):
+            stream.read(4)
+
+
 if __name__ == "__main__":
     from pyspark.testing import main
 
diff --git 
a/sql/core/src/main/scala/org/apache/spark/sql/execution/python/ColumnarArrowPythonRunner.scala
 
b/sql/core/src/main/scala/org/apache/spark/sql/execution/python/ColumnarArrowPythonRunner.scala
index dcc915d5021a..41a563a7302f 100644
--- 
a/sql/core/src/main/scala/org/apache/spark/sql/execution/python/ColumnarArrowPythonRunner.scala
+++ 
b/sql/core/src/main/scala/org/apache/spark/sql/execution/python/ColumnarArrowPythonRunner.scala
@@ -51,10 +51,22 @@ private[python] class 
ColumnarArrowPythonWithNamedArgumentRunner(
 
   override protected def runnerConf: Map[String, String] = super.runnerConf ++ 
pythonRunnerConf
 
-  override protected def writeUDF(dataOut: DataOutputStream): Unit = {
+  // The input schema travels in evalConf (key "input_type"), exactly like
+  // ArrowPythonWithNamedArgumentRunner. It must NOT be written into the UDF 
command section:
+  // the worker's WorkerInitInfo.from_stream reads that section as a UDF count 
followed by UDF
+  // entries, so an extra UTF string there desyncs the whole init-message 
parse -- the worker
+  // then blocks waiting for bytes past the end of the init message and the 
task hangs forever.
+  override protected def evalConf: Map[String, String] = {
     if (evalType == PythonEvalType.SQL_ARROW_BATCHED_UDF) {
-      PythonWorkerUtils.writeUTF(schema.json, dataOut)
+      super.evalConf ++ Map(
+        "input_type" -> schema.json
+      )
+    } else {
+      super.evalConf
     }
+  }
+
+  override protected def writeUDF(dataOut: DataOutputStream): Unit = {
     PythonUDFRunner.writeUDFs(dataOut, funcs, argMetas)
   }
 }


---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]

Reply via email to