andygrove opened a new issue, #5281:
URL: https://github.com/apache/datafusion-comet/issues/5281

   ### Describe the bug
   
   When a `ScalaUDF` (or Java UDF) is dispatched through the codegen dispatcher 
(`spark.comet.exec.scalaUDF.codegen.enabled=true`) and the class that captured 
the UDF closure comes from a user jar (`--jars` / `spark.jars`), the query 
fails at execution time:
   
   ```
   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
     in instance of org.apache.spark.sql.catalyst.expressions.ScalaUDF
        at 
java.base/java.io.ObjectStreamClass$FieldReflector.setObjFieldValues(ObjectStreamClass.java:2096)
        at 
java.base/java.io.ObjectStreamClass$FieldReflector.checkObjectFieldValueTypes(ObjectStreamClass.java:2060)
        at 
java.base/java.io.ObjectStreamClass.checkObjFieldValueTypes(ObjectStreamClass.java:1347)
        at 
java.base/java.io.ObjectInputStream$FieldValues.defaultCheckFieldValues(ObjectInputStream.java:2679)
        ...
        at 
org.apache.comet.udf.codegen.CometScalaUDFCodegen.lookupOrCompile(CometScalaUDFCodegen.scala:160)
        at 
org.apache.comet.udf.codegen.CometScalaUDFCodegen.evaluate(CometScalaUDFCodegen.scala:123)
        at 
org.apache.comet.udf.CometUdfBridge.evaluateInternal(CometUdfBridge.java:203)
        at org.apache.comet.udf.CometUdfBridge.evaluate(CometUdfBridge.java:122)
   ```
   
   ### Root cause
   
   The `ClassCastException` is a **masked `ClassNotFoundException`**. When the 
classloader used for deserialization 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 CNFE never surfaces.
   
   The loader is wrong because of where the deserialization runs. 
`CometScalaUDFCodegen.lookupOrCompile` deserializes the bound expression with 
the calling thread's context classloader:
   
   ```scala
   val loader = Option(Thread.currentThread().getContextClassLoader)
     .getOrElse(classOf[Expression].getClassLoader)
   ```
   
   `CometUdfBridge.evaluate` is invoked from a Tokio worker thread, which 
attaches to the JVM through JNI. **An attached thread has no context 
classloader**, so this falls back to `classOf[Expression].getClassLoader` — the 
loader that loaded spark-catalyst and Comet, which never contains user jars. 
Spark's executor classloader (`MutableURLClassLoader`, which does contain them) 
is only installed on Spark task threads.
   
   Measured, by returning thread identity from inside a UDF body:
   
   | stage leaf | thread running the UDF | context classloader |
   | --- | --- | --- |
   | `CometNativeScan` | `Thread-28..32` (Tokio workers) | **null** |
   | shuffle read / join build side | `Executor task launch worker …` | 
`MutableURLClassLoader` |
   | native scan disabled | `Executor task launch worker …` | 
`MutableURLClassLoader` |
   
   So any plan whose stage leaf is a native scan (`CometNativeScan`, 
`CometIcebergNativeScan`) evaluates UDFs on a Tokio worker and hits this. 
`CometUdfBridge` already propagates `TaskContext` across the JNI boundary for 
exactly this reason, but the classloader is not propagated.
   
   This is invisible to Comet's own test suites because they register UDFs from 
test classes that are already on the application classpath.
   
   `CometUdfBridge.java:180` has the same latent problem: a user-supplied 
`CometUDF` implementation shipped in a user jar would fail the same way at 
`Class.forName`.
   
   ### Steps to reproduce
   
   Requires two conditions together:
   
   1. The class capturing the UDF lambda is reachable only from Spark's 
executor classloader (a `--jars` submission), not from Comet's own classloader.
   2. The UDF is evaluated on a Tokio worker, i.e. the stage's leaf is a native 
scan.
   
   A self-contained failing test compiles a class holding a serializable 
`scala.Function1` lambda into a jar at test time and wires it in via 
`spark.executor.extraClassPath` (the local-mode equivalent of `--jars`: 
`LocalSchedulerBackend` feeds it into the executor's `MutableURLClassLoader`), 
then runs `SELECT hiddenUdf(s) FROM t`. It reproduces the trace above exactly. 
The same query with the native scan disabled passes, which isolates the failure 
to classloader propagation rather than class availability.
   
   Note that `sparkContext.addJar` is *not* sufficient to set this up in local 
mode — the jar does not reach the local executor's classloader, and the job 
then fails earlier, in Spark's own task deserialization.
   
   ### Expected behavior
   
   A UDF whose closure class lives in a user jar should execute regardless of 
which thread the native side dispatches from. The Spark task thread's context 
classloader should be captured at plan-creation time and propagated to the 
Tokio worker, alongside the `TaskContext` that is already propagated.
   


-- 
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.

To unsubscribe, e-mail: [email protected]

For queries about this service, please contact Infrastructure at:
[email protected]


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

Reply via email to