This is an automated email from the ASF dual-hosted git repository.
andygrove pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/datafusion-comet.git
The following commit(s) were added to refs/heads/main by this push:
new 003f608948 fix: propagate the Spark task ClassLoader to JVM UDF calls
(#5282)
003f608948 is described below
commit 003f608948ec386bbb666e32f5b14bc8cd01c0b8
Author: Andy Grove <[email protected]>
AuthorDate: Fri Aug 7 15:10:25 2026 -0600
fix: propagate the Spark task ClassLoader to JVM UDF calls (#5282)
---
.github/workflows/pr_build_linux.yml | 1 +
.github/workflows/pr_build_macos.yml | 1 +
docs/source/user-guide/latest/scala_java_udfs.md | 1 +
native/core/src/execution/jni_api.rs | 22 ++-
native/core/src/execution/planner.rs | 29 ++-
native/jni-bridge/src/comet_udf_bridge.rs | 4 +-
native/spark-expr/src/jvm_udf/mod.rs | 24 ++-
.../java/org/apache/comet/udf/CometUdfBridge.java | 38 +++-
.../scala/org/apache/comet/CometExecIterator.scala | 7 +-
spark/src/main/scala/org/apache/comet/Native.scala | 8 +-
.../comet/CometScalaUDFClassLoaderSuite.scala | 195 +++++++++++++++++++++
11 files changed, 302 insertions(+), 28 deletions(-)
diff --git a/.github/workflows/pr_build_linux.yml
b/.github/workflows/pr_build_linux.yml
index def2816607..3edaa3e569 100644
--- a/.github/workflows/pr_build_linux.yml
+++ b/.github/workflows/pr_build_linux.yml
@@ -402,6 +402,7 @@ jobs:
org.apache.comet.CometCodegenHOFSuite
org.apache.comet.CometFuzzMathSuite
org.apache.comet.CometCodegenFuzzSuite
+ org.apache.comet.CometScalaUDFClassLoaderSuite
org.apache.comet.codegen.CometSpecializedGettersDispatchSuite
org.apache.comet.CometStringDecodeSuite
org.apache.comet.CometWidthBucketSuite
diff --git a/.github/workflows/pr_build_macos.yml
b/.github/workflows/pr_build_macos.yml
index bac1dcd15b..42714de24c 100644
--- a/.github/workflows/pr_build_macos.yml
+++ b/.github/workflows/pr_build_macos.yml
@@ -218,6 +218,7 @@ jobs:
org.apache.comet.CometCodegenHOFSuite
org.apache.comet.CometFuzzMathSuite
org.apache.comet.CometCodegenFuzzSuite
+ org.apache.comet.CometScalaUDFClassLoaderSuite
org.apache.comet.codegen.CometSpecializedGettersDispatchSuite
org.apache.comet.CometStringDecodeSuite
org.apache.comet.CometWidthBucketSuite
diff --git a/docs/source/user-guide/latest/scala_java_udfs.md
b/docs/source/user-guide/latest/scala_java_udfs.md
index 1ebf79250f..3a55982bbc 100644
--- a/docs/source/user-guide/latest/scala_java_udfs.md
+++ b/docs/source/user-guide/latest/scala_java_udfs.md
@@ -54,6 +54,7 @@ When a UDF is rejected, the reason surfaces through Comet's
standard fallback di
- Non-deterministic expressions referenced from the argument tree (`rand`,
`uuid`, `monotonically_increasing_id`) produce per-partition sequences
consistent with Spark.
- `TaskContext.get()` inside the user function returns the driving Spark
task's context.
+- The Spark task thread's context ClassLoader is propagated to the thread that
runs the user function, so functions defined in jars supplied with `--jars` /
`spark.jars` resolve the same way they do under Spark's own execution.
- The user function must be closure-serializable; the same function that works
with Spark's executor execution works here.
## Known limitations
diff --git a/native/core/src/execution/jni_api.rs
b/native/core/src/execution/jni_api.rs
index f798b80335..2e98bf9aa7 100644
--- a/native/core/src/execution/jni_api.rs
+++ b/native/core/src/execution/jni_api.rs
@@ -352,6 +352,11 @@ struct ExecutionContext {
/// cheap to clone; the underlying `Global<JObject>` releases its JNI
global ref on drop
/// via `jni`'s `Drop` impl.
pub task_context: Option<Arc<Global<JObject<'static>>>>,
+ /// Context `ClassLoader` of the driving Spark task thread, captured at
`createPlan` time and
+ /// threaded into every JVM scalar UDF the planner builds; see
`CometUdfBridge.evaluate` for why
+ /// it has to travel with the plan. `None` when no driving Spark task is
present (unit tests,
+ /// direct native driver runs). Lifetime is as for `task_context` above.
+ pub class_loader: Option<Arc<Global<JObject<'static>>>>,
}
/// Accept serialized query plan and return the address of the native query
plan.
@@ -379,6 +384,7 @@ pub unsafe extern "system" fn
Java_org_apache_comet_Native_createPlan(
task_cpus: jlong,
key_unwrapper_obj: JObject,
task_context_obj: JObject,
+ class_loader_obj: JObject,
) -> jlong {
try_unwrap_or_throw(&e, |env| {
// Deserialize Spark configs
@@ -500,15 +506,21 @@ pub unsafe extern "system" fn
Java_org_apache_comet_Native_createPlan(
String::new()
};
- // Capture the driving Spark task's TaskContext as a JNI global
reference when
- // non-null. The `Arc<Global<JObject>>` releases its global ref on
drop, so cleanup
- // is automatic when the ExecutionContext drops.
+ // Capture the driving Spark task's TaskContext and context
ClassLoader as JNI global
+ // references when non-null. The `Arc<Global<JObject>>` releases
its global ref on
+ // drop, so cleanup is automatic when the ExecutionContext drops.
let task_context = if !task_context_obj.is_null() {
Some(Arc::new(jni_new_global_ref!(env, task_context_obj)?))
} else {
None
};
+ let class_loader = if !class_loader_obj.is_null() {
+ Some(Arc::new(jni_new_global_ref!(env, class_loader_obj)?))
+ } else {
+ None
+ };
+
let exec_context = Box::new(ExecutionContext {
id,
task_attempt_id,
@@ -536,6 +548,7 @@ pub unsafe extern "system" fn
Java_org_apache_comet_Native_createPlan(
),
tracing_event_name,
task_context,
+ class_loader,
});
Ok(Box::into_raw(exec_context) as i64)
@@ -782,7 +795,8 @@ pub unsafe extern "system" fn
Java_org_apache_comet_Native_executePlan(
PhysicalPlanner::new(Arc::clone(&exec_context.session_ctx), partition)
.with_exec_id(exec_context_id)
.with_sql_text_pool(&exec_context.spark_plan)
- .with_task_context(exec_context.task_context.clone());
+ .with_task_context(exec_context.task_context.clone())
+ .with_class_loader(exec_context.class_loader.clone());
let (scans, shuffle_scans, root_op) = planner.create_plan(
&exec_context.spark_plan,
&mut exec_context.input_sources.clone(),
diff --git a/native/core/src/execution/planner.rs
b/native/core/src/execution/planner.rs
index 43f066e76c..c179c3b57c 100644
--- a/native/core/src/execution/planner.rs
+++ b/native/core/src/execution/planner.rs
@@ -250,6 +250,10 @@ pub struct PhysicalPlanner {
/// Captured at `createPlan` time on `ExecutionContext`; see that struct
for the
/// propagation rationale. `None` when no driving Spark task is available.
task_context: Option<Arc<Global<JObject<'static>>>>,
+ /// Context `ClassLoader` of the driving Spark task thread, captured at
`createPlan` time on
+ /// `ExecutionContext`; see that struct for the propagation rationale.
`None` when no driving
+ /// Spark task is available.
+ class_loader: Option<Arc<Global<JObject<'static>>>>,
}
impl Default for PhysicalPlanner {
@@ -267,6 +271,7 @@ impl PhysicalPlanner {
query_context_registry:
datafusion_comet_spark_expr::create_query_context_map(),
sql_text_pool: vec![],
task_context: None,
+ class_loader: None,
}
}
@@ -356,6 +361,17 @@ impl PhysicalPlanner {
self
}
+ /// Attach the driving Spark task thread's context `ClassLoader` as a
global reference. Mirrors
+ /// `with_task_context`: called by the JNI `executePlan` entry with
whatever was captured at
+ /// `createPlan` time, and cloned into every `JvmScalarUdfExpr` the
planner builds.
+ pub fn with_class_loader(
+ mut self,
+ class_loader: Option<Arc<Global<JObject<'static>>>>,
+ ) -> Self {
+ self.class_loader = class_loader;
+ self
+ }
+
/// Return session context of this planner.
pub fn session_ctx(&self) -> &Arc<SessionContext> {
&self.session_ctx
@@ -876,12 +892,14 @@ impl PhysicalPlanner {
to_arrow_datatype(udf.return_type.as_ref().ok_or_else(|| {
GeneralError("JvmScalarUdf missing
return_type".to_string())
})?);
- // Invariant: task_context is propagated for every
JvmScalarUdfExpr built during
- // normal execution. The TEST_EXEC_CONTEXT_ID path is the only
context in which
- // task_context may legitimately be None (unit tests, direct
native driver runs).
+ // Invariant: task_context and class_loader are propagated for
every
+ // JvmScalarUdfExpr built during normal execution. The
TEST_EXEC_CONTEXT_ID path is
+ // the only context in which they may legitimately be None
(unit tests, direct
+ // native driver runs).
debug_assert!(
- self.task_context.is_some() || self.exec_context_id ==
TEST_EXEC_CONTEXT_ID,
- "task_context must be set for non-test execution"
+ (self.task_context.is_some() &&
self.class_loader.is_some())
+ || self.exec_context_id == TEST_EXEC_CONTEXT_ID,
+ "task_context and class_loader must be set for non-test
execution"
);
Ok(Arc::new(JvmScalarUdfExpr::new(
udf.class_name.clone(),
@@ -889,6 +907,7 @@ impl PhysicalPlanner {
return_type,
udf.return_nullable,
self.task_context.clone(),
+ self.class_loader.clone(),
)))
}
expr => Err(GeneralError(format!("Not implemented: {expr:?}"))),
diff --git a/native/jni-bridge/src/comet_udf_bridge.rs
b/native/jni-bridge/src/comet_udf_bridge.rs
index e531d20cb1..bbd7465d00 100644
--- a/native/jni-bridge/src/comet_udf_bridge.rs
+++ b/native/jni-bridge/src/comet_udf_bridge.rs
@@ -41,7 +41,9 @@ impl<'a> CometUdfBridge<'a> {
method_evaluate: env.get_static_method_id(
JNIString::new(Self::JVM_CLASS),
jni::jni_str!("evaluate"),
-
jni::jni_sig!("(Ljava/lang/String;[J[JJJILorg/apache/spark/TaskContext;)V"),
+ jni::jni_sig!(
+
"(Ljava/lang/String;[J[JJJILorg/apache/spark/TaskContext;Ljava/lang/ClassLoader;)V"
+ ),
)?,
method_evaluate_ret: ReturnType::Primitive(Primitive::Void),
class,
diff --git a/native/spark-expr/src/jvm_udf/mod.rs
b/native/spark-expr/src/jvm_udf/mod.rs
index 0ca603ac9b..8e148ef40a 100644
--- a/native/spark-expr/src/jvm_udf/mod.rs
+++ b/native/spark-expr/src/jvm_udf/mod.rs
@@ -48,6 +48,11 @@ pub struct JvmScalarUdfExpr {
/// Spark task is available; the bridge then leaves whatever
`TaskContext.get()` already
/// returns in place.
task_context: Option<Arc<Global<JObject<'static>>>>,
+ /// Context `ClassLoader` of the driving Spark task thread, captured at
`createPlan` time and
+ /// threaded here by the planner. See `CometUdfBridge.evaluate`, which
installs it for the
+ /// duration of the call. `None` when no driving Spark task is available
(unit tests, direct
+ /// native driver runs); the bridge then installs nothing.
+ class_loader: Option<Arc<Global<JObject<'static>>>>,
}
impl JvmScalarUdfExpr {
@@ -57,6 +62,7 @@ impl JvmScalarUdfExpr {
return_type: DataType,
return_nullable: bool,
task_context: Option<Arc<Global<JObject<'static>>>>,
+ class_loader: Option<Arc<Global<JObject<'static>>>>,
) -> Self {
debug_assert!(
!class_name.is_empty(),
@@ -68,6 +74,7 @@ impl JvmScalarUdfExpr {
return_type,
return_nullable,
task_context,
+ class_loader,
}
}
}
@@ -197,14 +204,17 @@ impl PhysicalExpr for JvmScalarUdfExpr {
.set_region(env, 0, &in_sch_ptrs)
.map_err(|e| CometError::JNI { source: e })?;
- // Resolve the TaskContext reference once before building the arg
array so the
- // borrow lives until `call_static_method_unchecked` returns. When
no TaskContext
- // was propagated, pass a null object so the bridge's null-guard
leaves the thread-
- // local alone.
- let null_task_context = JObject::null();
+ // Resolve the TaskContext and ClassLoader references once before
building the arg
+ // array so the borrows live until `call_static_method_unchecked`
returns. Absent
+ // values are passed as a null object, which the bridge's
null-guards skip.
+ let null_obj = JObject::null();
let task_context_ref: &JObject = match &self.task_context {
Some(gref) => gref.as_obj(),
- None => &null_task_context,
+ None => &null_obj,
+ };
+ let class_loader_ref: &JObject = match &self.class_loader {
+ Some(gref) => gref.as_obj(),
+ None => &null_obj,
};
let ret = unsafe {
env.call_static_method_unchecked(
@@ -219,6 +229,7 @@ impl PhysicalExpr for JvmScalarUdfExpr {
JValue::Long(out_sch_ptr).as_jni(),
JValue::Int(batch.num_rows() as i32).as_jni(),
JValue::Object(task_context_ref).as_jni(),
+ JValue::Object(class_loader_ref).as_jni(),
],
)
};
@@ -255,6 +266,7 @@ impl PhysicalExpr for JvmScalarUdfExpr {
self.return_type.clone(),
self.return_nullable,
self.task_context.clone(),
+ self.class_loader.clone(),
)))
}
}
diff --git a/spark/src/main/java/org/apache/comet/udf/CometUdfBridge.java
b/spark/src/main/java/org/apache/comet/udf/CometUdfBridge.java
index 9e97ef2226..d8dea73135 100644
--- a/spark/src/main/java/org/apache/comet/udf/CometUdfBridge.java
+++ b/spark/src/main/java/org/apache/comet/udf/CometUdfBridge.java
@@ -31,6 +31,8 @@ import org.apache.spark.TaskContext;
import org.apache.spark.comet.CometTaskContextShim;
import org.apache.spark.util.TaskCompletionListener;
+import org.apache.comet.util.ClassLoaders;
+
/**
* JNI entry point for native execution to invoke a {@link CometUDF}. Matches
the static-method
* pattern used by CometScalarSubquery so the native side can dispatch via
@@ -91,6 +93,17 @@ public class CometUdfBridge {
* left on a worker by a previous task. Its task attempt ID also keys
the UDF-instance cache,
* so a UDF holding per-task state in fields sees a consistent instance
for every call within
* the task regardless of which Tokio worker is polling.
+ * @param classLoader context ClassLoader captured on the driving Spark task
thread, or {@code
+ * null} outside a Spark task. Installed as this thread's context
ClassLoader for the duration
+ * of the call, with the prior value restored in {@code finally}. Tokio
workers attach through
+ * JNI and an attached thread has no context ClassLoader, so without
this every lookup falls
+ * back to the ClassLoader that loaded Comet, which never holds user
jars ({@code --jars} /
+ * {@code spark.jars}). Both the {@code CometUDF} resolution below and
the closure
+ * deserialization in {@code CometScalaUDFCodegen} depend on it.
Installed per call rather
+ * than once when the worker attaches, because the Tokio runtime is
process-global: one worker
+ * interleaves work from task attempts of different jobs, and under
Spark Connect from
+ * sessions with different artifact ClassLoaders. The loader is a
property of the plan, not of
+ * the thread.
*/
public static void evaluate(
String udfClassName,
@@ -99,7 +112,8 @@ public class CometUdfBridge {
long outArrayPtr,
long outSchemaPtr,
int numRows,
- TaskContext taskContext) {
+ TaskContext taskContext,
+ ClassLoader classLoader) {
assert udfClassName != null && !udfClassName.isEmpty() : "udfClassName
must be non-empty";
assert inputArrayPtrs != null && inputSchemaPtrs != null
: "input pointer arrays must be non-null";
@@ -111,13 +125,20 @@ public class CometUdfBridge {
// Save-and-restore rather than only-install-if-null: the propagated
`taskContext` is the
// ground truth for this call. Any value already on the thread is either
(a) the same object
- // on a Spark task thread, or (b) stale from a prior task on a reused
Tokio worker.
+ // on a Spark task thread, or (b) stale from a prior task on a reused
Tokio worker. The same
+ // reasoning applies to the propagated `classLoader`.
TaskContext prior = TaskContext.get();
if (taskContext != null) {
CometTaskContextShim.set(taskContext);
assert TaskContext.get() == taskContext
: "TaskContext install did not take effect on this thread";
}
+ Thread currentThread = Thread.currentThread();
+ ClassLoader priorLoader = currentThread.getContextClassLoader();
+ if (classLoader != null) {
+ currentThread.setContextClassLoader(classLoader);
+ }
+
try {
evaluateInternal(
udfClassName,
@@ -128,6 +149,9 @@ public class CometUdfBridge {
numRows,
taskContext);
} finally {
+ // Unconditional: a no-op when nothing was installed, and it also undoes
any change the
+ // user function made to the ClassLoader of a worker that outlives this
call.
+ currentThread.setContextClassLoader(priorLoader);
if (taskContext != null) {
if (prior != null) {
CometTaskContextShim.set(prior);
@@ -175,14 +199,10 @@ public class CometUdfBridge {
udfClassName,
name -> {
try {
- // Resolve via the executor's context classloader so
user-supplied UDF jars
- // (added via spark.jars / --jars) are visible.
- ClassLoader cl =
Thread.currentThread().getContextClassLoader();
- if (cl == null) {
- cl = CometUdfBridge.class.getClassLoader();
- }
+ // Resolves through the context ClassLoader installed by
`evaluate`, so a
+ // user-supplied CometUDF shipped in a user jar is visible.
return (CometUDF)
- Class.forName(name, true,
cl).getDeclaredConstructor().newInstance();
+
ClassLoaders.loadClass(name).getDeclaredConstructor().newInstance();
} catch (ReflectiveOperationException e) {
throw new RuntimeException("Failed to instantiate CometUDF: "
+ name, e);
}
diff --git a/spark/src/main/scala/org/apache/comet/CometExecIterator.scala
b/spark/src/main/scala/org/apache/comet/CometExecIterator.scala
index a3216404ac..57578235f7 100644
--- a/spark/src/main/scala/org/apache/comet/CometExecIterator.scala
+++ b/spark/src/main/scala/org/apache/comet/CometExecIterator.scala
@@ -125,8 +125,11 @@ class CometExecIterator(
taskCPUs,
keyUnwrapper,
// Propagated to Tokio workers running JVM UDFs so they see this Spark
task's
- // TaskContext. See CometUdfBridge.evaluate.
- TaskContext.get())
+ // TaskContext and context ClassLoader. Read here because this class is
only ever
+ // constructed on a Spark task thread (see `taskAttemptId` above); a
JNI-attached Tokio
+ // worker has neither. See CometUdfBridge.evaluate.
+ TaskContext.get(),
+ Thread.currentThread().getContextClassLoader)
}
private var nextBatch: Option[ColumnarBatch] = None
diff --git a/spark/src/main/scala/org/apache/comet/Native.scala
b/spark/src/main/scala/org/apache/comet/Native.scala
index 3cfa51b6e1..51b3e5e41b 100644
--- a/spark/src/main/scala/org/apache/comet/Native.scala
+++ b/spark/src/main/scala/org/apache/comet/Native.scala
@@ -48,6 +48,11 @@ class Native extends NativeBase {
* @param taskMemoryManager
* the task-level memory manager that is responsible for tracking memory
usage across JVM and
* native side.
+ * @param taskContext
+ * the driving Spark task's `TaskContext`, propagated to Tokio workers
running JVM UDFs.
+ * @param classLoader
+ * the calling Spark task thread's context ClassLoader, propagated to
Tokio workers running
+ * JVM UDFs. Must be read on the task thread; see
`CometUdfBridge.evaluate`.
* @return
* the address to native query plan.
*/
@@ -70,7 +75,8 @@ class Native extends NativeBase {
taskAttemptId: Long,
taskCPUs: Long,
keyUnwrapper: CometFileKeyUnwrapper,
- taskContext: TaskContext): Long
+ taskContext: TaskContext,
+ classLoader: ClassLoader): Long
// scalastyle:on
/**
diff --git
a/spark/src/test/scala/org/apache/comet/CometScalaUDFClassLoaderSuite.scala
b/spark/src/test/scala/org/apache/comet/CometScalaUDFClassLoaderSuite.scala
new file mode 100644
index 0000000000..17fd0fea09
--- /dev/null
+++ b/spark/src/test/scala/org/apache/comet/CometScalaUDFClassLoaderSuite.scala
@@ -0,0 +1,195 @@
+/*
+ * 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.comet
+
+import java.io.File
+import java.net.URLClassLoader
+import java.nio.charset.StandardCharsets.UTF_8
+import java.nio.file.{Files, Path, Paths}
+import javax.tools.ToolProvider
+
+import org.apache.spark.SparkConf
+import org.apache.spark.sql.{CometTestBase, Encoders}
+import org.apache.spark.sql.catalyst.expressions.Expression
+
+/**
+ * Regression coverage for UDF closures whose capturing class lives in a user
jar, which used to
+ * fail with:
+ *
+ * {{{
+ * java.lang.ClassCastException: cannot assign instance of
java.lang.invoke.SerializedLambda
+ * to field org.apache.spark.sql.catalyst.expressions.ScalaUDF.f of type
scala.Function1
+ * at org.apache.comet.udf.codegen.CometScalaUDFCodegen.lookupOrCompile
+ * }}}
+ *
+ * That exception is a masked `ClassNotFoundException`: when the deserializing
ClassLoader cannot
+ * resolve a lambda's capturing class, `ObjectInputStream` records the CNFE
against the object
+ * handle, therefore skips `SerializedLambda.readResolve`, and the raw
`SerializedLambda` then
+ * fails the field-type check in `defaultCheckFieldValues`. The classloading
rationale lives on
+ * `CometUdfBridge.evaluate`.
+ *
+ * The fixture puts the capturing class on `spark.executor.extraClassPath`,
the local-mode
+ * equivalent of a `--jars` submission: `LocalSchedulerBackend` feeds it into
the executor's
+ * `MutableURLClassLoader`, which becomes the task thread's context
ClassLoader while staying
+ * invisible to the ClassLoader that loaded Comet.
+ */
+class CometScalaUDFClassLoaderSuite extends CometTestBase {
+
+ import CometScalaUDFClassLoaderSuite._
+
+ override protected def sparkConf: SparkConf =
+ super.sparkConf
+ .set(CometConf.COMET_SCALA_UDF_CODEGEN_ENABLED.key, "true")
+ .set("spark.executor.extraClassPath", hiddenClassesDir.toString)
+
+ private def withHiddenUdfTable(f: => Unit): Unit = {
+ spark.udf.register("hiddenUdf", hiddenFn)
+ withTable("t") {
+ sql("CREATE TABLE t (s STRING) USING parquet")
+ sql("INSERT INTO t VALUES ('a'), ('b'), (NULL)")
+ f
+ }
+ }
+
+ test("fixture: hidden class reachable from task threads, not from Comet's
ClassLoader") {
+ // Guards the tests below from passing vacuously.
`classOf[Expression].getClassLoader` is the
+ // fallback `lookupOrCompile` uses when the calling thread has no context
ClassLoader.
+ intercept[ClassNotFoundException] {
+ Class.forName(HiddenClassName, false, classOf[Expression].getClassLoader)
+ }
+ val reachable = spark
+ .range(4)
+ .repartition(2)
+ .mapPartitions(_ => Iterator(canLoadHiddenClass))(Encoders.scalaBoolean)
+ .collect()
+ assert(reachable.forall(identity), s"task threads could not load
$HiddenClassName")
+ }
+
+ // Both leaf shapes matter. With the native scan the dispatcher runs on a
Tokio worker, which has
+ // no context ClassLoader of its own and depends on the propagated one;
without it the dispatcher
+ // runs on the Spark task thread, which already has the executor's
ClassLoader installed.
+ Seq(true, false).foreach { nativeScan =>
+ test(s"ScalaUDF closure captured by a user-jar class,
nativeScan=$nativeScan") {
+ withSQLConf(CometConf.COMET_NATIVE_SCAN_ENABLED.key ->
nativeScan.toString) {
+ withHiddenUdfTable {
+ checkSparkAnswer(sql("SELECT hiddenUdf(s) FROM t"))
+ }
+ }
+ }
+ }
+
+ test("the thread running the UDF sees the task thread's context
ClassLoader") {
+ // Pins the propagation itself rather than its symptom: the UDF body
reports whether the
+ // ClassLoader installed on whatever thread invoked it can reach the user
jar.
+ spark.udf.register("loaderProbe", (_: String) => loaderReport())
+ withHiddenUdfTable {
+ val reports = sql("SELECT loaderProbe(s) FROM
t").collect().map(_.getString(0)).distinct
+ assert(
+ reports.forall(_.startsWith("loaded|")),
+ s"UDF thread could not load $HiddenClassName: ${reports.mkString(",
")}")
+ }
+ }
+}
+
+object CometScalaUDFClassLoaderSuite {
+
+ val HiddenClassName = "hidden.HiddenUdf"
+
+ /**
+ * Directory holding the compiled capturing class. Compiled once per JVM,
before the
+ * SparkSession starts, because `spark.executor.extraClassPath` is read at
session creation. A
+ * directory works as a classpath entry, so there is no need to package a
jar.
+ */
+ lazy val hiddenClassesDir: Path = compileHiddenClass()
+
+ /**
+ * The UDF, obtained through a ClassLoader over `hiddenClassesDir` alone.
Deliberately not
+ * closed: the loaded class stays live in the registered UDF for the rest of
the JVM's life.
+ */
+ lazy val hiddenFn: String => String =
+ new URLClassLoader(Array(hiddenClassesDir.toUri.toURL),
getClass.getClassLoader)
+ .loadClass(HiddenClassName)
+ .getMethod("make")
+ .invoke(null)
+ .asInstanceOf[String => String]
+
+ /** Runs on Spark threads; kept on the companion so the closures capture
nothing. */
+ def canLoadHiddenClass: Boolean =
+ try {
+ Class.forName(HiddenClassName, false,
Thread.currentThread().getContextClassLoader)
+ true
+ } catch {
+ case _: ClassNotFoundException => false
+ }
+
+ /**
+ * Reports whether the current thread's context ClassLoader reaches the user
jar, tagged with
+ * the thread name so a failure says which thread was missing it.
+ */
+ def loaderReport(): String = {
+ val status = if (canLoadHiddenClass) "loaded" else "MISSING"
+ s"$status|${Thread.currentThread().getName}"
+ }
+
+ private def compileHiddenClass(): Path = {
+ // createTempDirectory does not create its parent (java.io.tmpdir, pinned
to target/tmp by the
+ // pom), which does not exist yet on a fresh checkout, so make it up front.
+ val tmpRoot =
Files.createDirectories(Paths.get(System.getProperty("java.io.tmpdir")))
+ val workDir = Files.createTempDirectory(tmpRoot, "comet-hidden-udf")
+ val src = workDir.resolve("HiddenUdf.java")
+ // The intersection cast is what makes the lambda serializable, and
therefore what makes
+ // `hidden.HiddenUdf` the capturing class recorded in the SerializedLambda.
+ Files.write(
+ src,
+ """package hidden;
+ |
+ |import java.io.Serializable;
+ |import scala.Function1;
+ |
+ |public class HiddenUdf {
+ | public static Function1<Object, Object> make() {
+ | return (Function1<Object, Object> & Serializable)
+ | (Object o) -> (o == null ? null : "hidden:" + o);
+ | }
+ |}
+ |""".stripMargin.getBytes(UTF_8))
+
+ val classesDir = Files.createDirectories(workDir.resolve("classes"))
+ val compiler = ToolProvider.getSystemJavaCompiler
+ assert(compiler != null, "test must run on a JDK (needs the javax.tools
compiler)")
+ // Only scala-library is needed. Handing javac the whole test classpath
makes it open and index
+ // every jar on it, which costs more than the compile itself.
+ val classpath = Option(classOf[Function1[_,
_]].getProtectionDomain.getCodeSource)
+ .map(_.getLocation.getPath)
+ .getOrElse(System.getProperty("java.class.path"))
+ val rc =
+ compiler.run(null, null, null, "-cp", classpath, "-d",
classesDir.toString, src.toString)
+ assert(rc == 0, s"javac failed with exit code $rc")
+
+ deleteOnExitRecursively(workDir.toFile)
+ classesDir
+ }
+
+ /** Parents are registered before children, and deletion runs in reverse
registration order. */
+ private def deleteOnExitRecursively(file: File): Unit = {
+ file.deleteOnExit()
+ Option(file.listFiles()).foreach(_.foreach(deleteOnExitRecursively))
+ }
+}
---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]