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

HyukjinKwon pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/spark.git


The following commit(s) were added to refs/heads/master by this push:
     new 1d9f7989802d [SPARK-59822][UDF] Make the external UDF worker 
dispatcher factory configurable
1d9f7989802d is described below

commit 1d9f7989802d644810f95741d66cd3995147531d
Author: Yossi Eliaz <[email protected]>
AuthorDate: Wed Sep 30 07:30:47 2026 +0900

    [SPARK-59822][UDF] Make the external UDF worker dispatcher factory 
configurable
    
    ### What changes were proposed in this pull request?
    
    Add the optional `spark.udf.worker.dispatcherFactory` setting, tracked by 
[SPARK-59822](https://issues.apache.org/jira/browse/SPARK-59822). When an 
external UDF first needs a dispatcher, `SparkEnv` loads the configured 
`UDFDispatcherFactory` using Spark's class loader and supports constructors 
taking `(SparkConf, Boolean)`, `SparkConf`, or no arguments. Factory validation 
does not initialize an incompatible class. Lazy manager creation and 
`SparkEnv.stop()` share a lifecycle gate, so  [...]
    
    A custom factory is a deployment-mode hook, not another client protocol. It 
must consume the standard `UDFWorkerSpecification` and use the unified UDF 
protocol. Spark should also provide a generic default dispatcher so most 
clients only describe their worker; that default and the shading/runtime wiring 
for the gRPC transport are separate work. Until then, an unset factory still 
fails on first external UDF dispatcher use, with an error naming the setting.
    
    ### Why are the changes needed?
    
    The factory currently installed by `SparkEnv` throws for every external 
UDF. This hook lets deployments supply an implementation without adding a 
compile-time gRPC dependency to `core`. The lifecycle coordination prevents an 
in-flight task from publishing a manager after shutdown has passed its close 
check.
    
    ### Does this PR introduce _any_ user-facing change?
    
    Yes. It adds an experimental operator-level setting for selecting an 
external UDF worker factory. Existing deployments behave the same when the 
setting is unset; the first attempt to create an external UDF dispatcher still 
fails, now with a message naming the setting.
    
    ### How was this patch tested?
    
    `SparkEnvUDFDispatcherSuite` covers the unset setting, no-argument and 
SparkConf/driver-aware constructors, an incompatible class (including its 
initializer), an unknown class, and manager creation racing with shutdown. 
`PlanExternalUDFsSuite` verifies the updated error at the SQL execution 
boundary. CI for the latest follow-up commit is pending; the local sparse 
checkout lacks the Spark build wrapper and Scala build tools.
    
    ### Was this patch authored or co-authored using generative AI tooling?
    
    Generated-by: Claude Opus 5 (original patch); Codex GPT-6 (version 
correction, lifecycle fix, tests, and PR description).
    
    Closes #58859 from zozo123/SPARK-udf-dispatcher-factory.
    
    Authored-by: Yossi Eliaz <[email protected]>
    Signed-off-by: Hyukjin Kwon <[email protected]>
---
 .../src/main/scala/org/apache/spark/SparkEnv.scala |  92 +++++++---
 .../org/apache/spark/internal/config/UDF.scala     |  34 ++++
 .../apache/spark/SparkEnvUDFDispatcherSuite.scala  | 196 +++++++++++++++++++++
 .../externalUDF/PlanExternalUDFsSuite.scala        |   5 +-
 4 files changed, 300 insertions(+), 27 deletions(-)

diff --git a/core/src/main/scala/org/apache/spark/SparkEnv.scala 
b/core/src/main/scala/org/apache/spark/SparkEnv.scala
index f8e2ae3abcca..04b75c4bcee3 100644
--- a/core/src/main/scala/org/apache/spark/SparkEnv.scala
+++ b/core/src/main/scala/org/apache/spark/SparkEnv.scala
@@ -51,7 +51,8 @@ import org.apache.spark.shuffle.{ShuffleBlockResolver, 
ShuffleManager}
 import org.apache.spark.shuffle.streaming.MultiShuffleManager
 import org.apache.spark.storage._
 import org.apache.spark.udf.worker.UDFWorkerSpecification
-import org.apache.spark.udf.worker.core.{UDFDispatcherFactory, 
UDFDispatcherManager, WorkerDispatcher}
+import org.apache.spark.udf.worker.core.{UDFDispatcherFactory, 
UDFDispatcherManager,
+  WorkerDispatcher, WorkerLogger}
 import org.apache.spark.util.{RpcUtils, Utils}
 import org.apache.spark.util.ArrayImplicits._
 import org.apache.spark.util.SparkUDFWorkerLogger
@@ -222,20 +223,12 @@ class SparkEnv (
    * using the new UDF framework proposed in SPARK-55278.
    * Initialized on first use via [[getExternalUDFDispatcher]].
    */
-  @volatile private var udfDispatcherManager: Option[UDFDispatcherManager] = 
None
+  // Guarded by this SparkEnv's monitor, together with isStopped.
+  private var udfDispatcherManager: Option[UDFDispatcherManager] = None
 
   private def createUDFDispatcherManager(): UDFDispatcherManager = {
-    val factory = new UDFDispatcherFactory {
-      override def createDispatcher(
-          workerSpec: UDFWorkerSpecification,
-          logger: org.apache.spark.udf.worker.core.WorkerLogger
-      ): WorkerDispatcher = {
-        // TODO [SPARK-55278]: Wire in the correct dispatcher factory
-        throw new UnsupportedOperationException(
-          "No UDF dispatcher factory configured. " +
-            "Set up a concrete factory for SPARK-55278.")
-      }
-    }
+    val factory = SparkEnv.resolveUDFDispatcherFactory(
+      conf, SparkContext.isDriver(executorId))
     new UDFDispatcherManager(factory, new SparkUDFWorkerLogger())
   }
 
@@ -246,15 +239,14 @@ class SparkEnv (
    */
   private[spark] def getExternalUDFDispatcher(
       workerSpec: UDFWorkerSpecification): WorkerDispatcher = {
-    val manager : UDFDispatcherManager = udfDispatcherManager.getOrElse {
-      synchronized {
-        // Get or Else synchronized to protect
-        // against concurrent creation requests.
-        udfDispatcherManager.getOrElse {
-          val created = createUDFDispatcherManager()
-          udfDispatcherManager = Some(created)
-          created
-        }
+    val manager = synchronized {
+      if (isStopped) {
+        throw new IllegalStateException("SparkEnv is stopped")
+      }
+      udfDispatcherManager.getOrElse {
+        val created = createUDFDispatcherManager()
+        udfDispatcherManager = Some(created)
+        created
       }
     }
     manager.getDispatcher(workerSpec)
@@ -283,9 +275,15 @@ class SparkEnv (
     new AtomicReference[VersionedCredentials]()
 
   private[spark] def stop(): Unit = {
-
-    if (!isStopped) {
-      isStopped = true
+    val shouldStop = synchronized {
+      if (isStopped) {
+        false
+      } else {
+        isStopped = true
+        true
+      }
+    }
+    if (shouldStop) {
       pythonWorkers.values.foreach(_.stop())
       udfDispatcherManager.foreach(_.close())
       mapOutputTracker.stop()
@@ -527,6 +525,50 @@ object SparkEnv extends Logging {
   private[spark] val driverSystemName = "sparkDriver"
   private[spark] val executorSystemName = "sparkExecutor"
 
+  /**
+   * :: Experimental ::
+   * Resolves the [[UDFDispatcherFactory]] used to create dispatchers for 
external UDF
+   * workers (SPARK-55278), from `spark.udf.worker.dispatcherFactory`.
+   *
+   * The factory is loaded reflectively rather than named here: `core` depends 
on
+   * `udf-worker-proto` and `udf-worker-core` only, so that neither it nor its 
consumers
+   * carry a gRPC dependency. Every concrete dispatcher therefore lives in a 
module that
+   * core cannot reference at compile time, and the implementation has to be 
selected by
+   * configuration.
+   *
+   * When the config is unset the returned factory throws on first use rather 
than at
+   * SparkEnv creation, so applications that never run an external UDF are 
unaffected.
+   */
+  private[spark] def resolveUDFDispatcherFactory(
+      conf: SparkConf,
+      isDriver: Boolean): UDFDispatcherFactory = {
+    conf.get(UDF.DISPATCHER_FACTORY) match {
+      case Some(className) =>
+        // Validate before constructing so that a misconfigured class is 
reported against
+        // the config key, rather than as a ClassCastException from the call 
site.
+        val cls = Utils.classForName[AnyRef](className, initialize = false)
+        if (!classOf[UDFDispatcherFactory].isAssignableFrom(cls)) {
+          throw new SparkException(
+            s"${UDF.DISPATCHER_FACTORY.key} is set to $className, which does 
not implement " +
+              s"${classOf[UDFDispatcherFactory].getName}.")
+        }
+        Utils.instantiateSerializerOrShuffleManager[UDFDispatcherFactory](
+          className, conf, isDriver)
+
+      case None =>
+        new UDFDispatcherFactory {
+          override def createDispatcher(
+              workerSpec: UDFWorkerSpecification,
+              logger: WorkerLogger): WorkerDispatcher = {
+            throw new UnsupportedOperationException(
+              "No UDF dispatcher factory is configured, so external UDF 
workers cannot be " +
+                s"created. Set ${UDF.DISPATCHER_FACTORY.key} to the name of a 
" +
+                s"${classOf[UDFDispatcherFactory].getName} implementation.")
+          }
+        }
+    }
+  }
+
   def set(e: SparkEnv): Unit = {
     env = e
   }
diff --git a/core/src/main/scala/org/apache/spark/internal/config/UDF.scala 
b/core/src/main/scala/org/apache/spark/internal/config/UDF.scala
new file mode 100644
index 000000000000..af536cd5fb0b
--- /dev/null
+++ b/core/src/main/scala/org/apache/spark/internal/config/UDF.scala
@@ -0,0 +1,34 @@
+/*
+ * 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.
+ */
+package org.apache.spark.internal.config
+
+private[spark] object UDF {
+
+  val DISPATCHER_FACTORY = ConfigBuilder("spark.udf.worker.dispatcherFactory")
+    .doc("Name of the class implementing " +
+      "org.apache.spark.udf.worker.core.UDFDispatcherFactory, used to create 
the " +
+      "dispatchers that provision and manage external UDF workers 
(SPARK-55278). The " +
+      "class must have a no-arg constructor, a constructor taking a SparkConf, 
or a " +
+      "constructor taking a SparkConf and a Boolean indicating whether it is 
being " +
+      "created on the driver. When unset, no external UDF dispatcher is 
available and " +
+      "executing an external UDF fails. This is an operator-level setting: it 
selects " +
+      "the runtime that user code is executed by, so it is not intended to be 
changed " +
+      "by individual queries.")
+    .version("4.4.0")
+    .stringConf
+    .createOptional
+}
diff --git 
a/core/src/test/scala/org/apache/spark/SparkEnvUDFDispatcherSuite.scala 
b/core/src/test/scala/org/apache/spark/SparkEnvUDFDispatcherSuite.scala
new file mode 100644
index 000000000000..886bbafbbdc8
--- /dev/null
+++ b/core/src/test/scala/org/apache/spark/SparkEnvUDFDispatcherSuite.scala
@@ -0,0 +1,196 @@
+/*
+ * 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.
+ */
+package org.apache.spark
+
+import java.util.concurrent.{CountDownLatch, TimeUnit}
+
+import scala.concurrent.{ExecutionContext, Future}
+import scala.concurrent.duration._
+import scala.util.Try
+
+import org.mockito.Answers.RETURNS_DEEP_STUBS
+import org.mockito.Mockito.{mock, verify}
+
+import org.apache.spark.broadcast.BroadcastManager
+import org.apache.spark.internal.config.UDF
+import org.apache.spark.metrics.MetricsSystem
+import org.apache.spark.rpc.RpcEnv
+import org.apache.spark.scheduler.OutputCommitCoordinator
+import org.apache.spark.storage.BlockManager
+import org.apache.spark.udf.worker.UDFWorkerSpecification
+import org.apache.spark.udf.worker.core.{UDFDispatcherFactory, 
WorkerDispatcher, WorkerLogger}
+import org.apache.spark.util.ThreadUtils
+
+class SparkEnvUDFDispatcherSuite extends SparkFunSuite {
+
+  private def spec = UDFWorkerSpecification.getDefaultInstance
+
+  private def newEnv(conf: SparkConf): SparkEnv = {
+    new SparkEnv(
+      SparkContext.DRIVER_IDENTIFIER,
+      mock(classOf[RpcEnv]),
+      null,
+      null,
+      null,
+      mock(classOf[MapOutputTracker]),
+      mock(classOf[BroadcastManager]),
+      mock(classOf[BlockManager], RETURNS_DEEP_STUBS),
+      null,
+      mock(classOf[MetricsSystem]),
+      mock(classOf[OutputCommitCoordinator]),
+      conf)
+  }
+
+  test("no factory configured: dispatcher creation fails with an actionable 
message") {
+    val factory = SparkEnv.resolveUDFDispatcherFactory(new SparkConf(false), 
isDriver = true)
+    // Resolution itself must succeed: applications that never run an external 
UDF should not
+    // fail at SparkEnv creation just because no dispatcher is configured.
+    val e = intercept[UnsupportedOperationException] {
+      factory.createDispatcher(spec, WorkerLogger.NoOp)
+    }
+    assert(e.getMessage.contains(UDF.DISPATCHER_FACTORY.key))
+  }
+
+  test("configured factory is instantiated and used") {
+    val conf = new SparkConf(false)
+      .set(UDF.DISPATCHER_FACTORY, classOf[TestNoArgDispatcherFactory].getName)
+    val factory = SparkEnv.resolveUDFDispatcherFactory(conf, isDriver = true)
+    assert(factory.isInstanceOf[TestNoArgDispatcherFactory])
+    assert(factory.createDispatcher(spec, WorkerLogger.NoOp) === null)
+  }
+
+  test("factory receives the SparkConf and isDriver when it declares them") {
+    val conf = new SparkConf(false)
+      .set(UDF.DISPATCHER_FACTORY, classOf[TestConfDispatcherFactory].getName)
+      .set("spark.test.marker", "set-by-conf")
+    Seq(true, false).foreach { isDriver =>
+      val factory = SparkEnv.resolveUDFDispatcherFactory(conf, isDriver)
+        .asInstanceOf[TestConfDispatcherFactory]
+      assert(factory.conf.get("spark.test.marker") === "set-by-conf")
+      assert(factory.isDriver === isDriver)
+    }
+  }
+
+  test("a class that is not a UDFDispatcherFactory is rejected against the 
config key") {
+    val conf = new SparkConf(false)
+      .set(UDF.DISPATCHER_FACTORY, classOf[NotAFactory].getName)
+    val e = intercept[SparkException] {
+      SparkEnv.resolveUDFDispatcherFactory(conf, isDriver = true)
+    }
+    assert(e.getMessage.contains(UDF.DISPATCHER_FACTORY.key))
+    assert(e.getMessage.contains(classOf[NotAFactory].getName))
+  }
+
+  test("an incompatible factory class is rejected before its initializer 
runs") {
+    val key = "spark.test.incompatibleFactoryInitialized"
+    System.clearProperty(key)
+    try {
+      val conf = new SparkConf(false)
+        .set(UDF.DISPATCHER_FACTORY, 
"org.apache.spark.NotAFactoryWithInitializer$")
+      intercept[SparkException] {
+        SparkEnv.resolveUDFDispatcherFactory(conf, isDriver = true)
+      }
+      assert(System.getProperty(key) == null)
+    } finally {
+      System.clearProperty(key)
+    }
+  }
+
+  test("stop waits for lazy dispatcher manager creation and closes created 
dispatchers") {
+    val factory = TestBlockingDispatcherFactory
+    val conf = new SparkConf(false)
+      .set(UDF.DISPATCHER_FACTORY, 
classOf[TestBlockingDispatcherFactory].getName)
+    val env = newEnv(conf)
+    val pool = ThreadUtils.newDaemonFixedThreadPool(2, "udf-dispatcher-test")
+    implicit val executionContext: ExecutionContext = 
ExecutionContext.fromExecutor(pool)
+    val creator = Future { Try(env.getExternalUDFDispatcher(spec)) }
+    try {
+      assert(factory.entered.await(10, TimeUnit.SECONDS))
+      val stopStarted = new CountDownLatch(1)
+      val stopFinished = new CountDownLatch(1)
+      val stopper = Future {
+        stopStarted.countDown()
+        env.stop()
+        stopFinished.countDown()
+      }
+      assert(stopStarted.await(10, TimeUnit.SECONDS))
+      assert(!stopFinished.await(100, TimeUnit.MILLISECONDS))
+      factory.release.countDown()
+      val result = ThreadUtils.awaitResult(creator, 30.seconds)
+      ThreadUtils.awaitResult(stopper, 30.seconds)
+      assert(result.isSuccess || 
result.failed.get.isInstanceOf[IllegalStateException])
+      if (factory.dispatcherCreated) {
+        verify(factory.dispatcher).close()
+      }
+      intercept[IllegalStateException] {
+        env.getExternalUDFDispatcher(spec)
+      }
+    } finally {
+      factory.release.countDown()
+      pool.shutdownNow()
+    }
+  }
+
+  test("an unknown class name fails to resolve") {
+    val conf = new SparkConf(false).set(UDF.DISPATCHER_FACTORY, 
"not.a.real.Factory")
+    intercept[ClassNotFoundException] {
+      SparkEnv.resolveUDFDispatcherFactory(conf, isDriver = true)
+    }
+  }
+}
+
+// Declared as top-level classes so that they have constructors Spark can find 
reflectively.
+
+class TestNoArgDispatcherFactory extends UDFDispatcherFactory {
+  override def createDispatcher(
+      workerSpec: UDFWorkerSpecification,
+      logger: WorkerLogger): WorkerDispatcher = null
+}
+
+class TestConfDispatcherFactory(val conf: SparkConf, val isDriver: Boolean)
+  extends UDFDispatcherFactory {
+  override def createDispatcher(
+      workerSpec: UDFWorkerSpecification,
+      logger: WorkerLogger): WorkerDispatcher = null
+}
+
+class NotAFactory
+
+object NotAFactoryWithInitializer {
+  System.setProperty("spark.test.incompatibleFactoryInitialized", "true")
+}
+
+object TestBlockingDispatcherFactory {
+  val entered = new CountDownLatch(1)
+  val release = new CountDownLatch(1)
+  val dispatcher: WorkerDispatcher = mock(classOf[WorkerDispatcher])
+  @volatile var dispatcherCreated = false
+}
+
+class TestBlockingDispatcherFactory extends UDFDispatcherFactory {
+  TestBlockingDispatcherFactory.entered.countDown()
+  if (!TestBlockingDispatcherFactory.release.await(30, TimeUnit.SECONDS)) {
+    throw new IllegalStateException("Timed out waiting to create the test 
dispatcher factory")
+  }
+
+  override def createDispatcher(
+      workerSpec: UDFWorkerSpecification,
+      logger: WorkerLogger): WorkerDispatcher = {
+    TestBlockingDispatcherFactory.dispatcherCreated = true
+    TestBlockingDispatcherFactory.dispatcher
+  }
+}
diff --git 
a/sql/core/src/test/scala/org/apache/spark/sql/execution/externalUDF/PlanExternalUDFsSuite.scala
 
b/sql/core/src/test/scala/org/apache/spark/sql/execution/externalUDF/PlanExternalUDFsSuite.scala
index 5161128f1103..39a70100f82a 100644
--- 
a/sql/core/src/test/scala/org/apache/spark/sql/execution/externalUDF/PlanExternalUDFsSuite.scala
+++ 
b/sql/core/src/test/scala/org/apache/spark/sql/execution/externalUDF/PlanExternalUDFsSuite.scala
@@ -731,8 +731,9 @@ class PlanExternalUDFsSuite extends QueryTest with 
SharedSparkSession {
     val exception = intercept[SparkException] {
       execution.execute().collect()
     }
-    assert(Utils.exceptionString(exception).contains(
-      "No UDF dispatcher factory configured. Set up a concrete factory for 
SPARK-55278."))
+    val message = Utils.exceptionString(exception)
+    assert(message.contains("No UDF dispatcher factory is configured"))
+    assert(message.contains("spark.udf.worker.dispatcherFactory"))
   }
 
   test("map partitions external UDF execution reports unimplemented before 
worker startup") {


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

Reply via email to