[AURON #2386] Resolve deserialized expression classes with an explicit class loader - #2395
Conversation
fd757e0 to
6387ca9
Compare
|
It took me a while to figure it out. Now I can successfully reproduce the bug in GitHub CI Test spark-4.1 JDK17 Scala-2.13 with bloomFilter optimizer enabled. |
… enabled Adds a spark-4.1 / JDK17 / Scala-2.13 TPC-DS job that turns on Spark's runtime bloom filter optimization: spark.sql.optimizer.runtime.bloomFilter.enabled=true spark.sql.optimizer.runtime.bloomFilter.applicationSideScanSizeThreshold=1B spark.sql.autoBroadcastJoinThreshold=-1 The low scan-size threshold makes InjectRuntimeFilter eligible on the TPC-DS queries, and disabling broadcast joins forces sort-merge joins so the runtime filter is injected as an execution-side ScalarSubquery rather than being folded into a broadcast exchange.
…e deserialization defect The bloom-filter TPC-DS job could never have caught AURON apache#2386. tpcds-reusable.yml copies the Auron jar into $SPARK_HOME/jars in addition to passing it through spark-submit --jars. MutableURLClassLoader is parent-first, so the $SPARK_HOME/jars copy wins and NativeConverters is defined by the application class loader, which can see every Auron class. The default resolveClass then resolves the whole expression graph correctly no matter which frame VM.latestUserDefinedLoader() selects, and the ClassCastException cannot occur. Disabling broadcast joins was necessary but not sufficient; both conditions have to hold at once. Add a jar-on-system-classpath input, default 'true' so every other job is unchanged, and set it to 'false' for the bloom-filter job so Auron reaches the JVM only through --jars. That alone still would not fail the build. Task-level deserialization failures are absorbed by Spark's task retries: at sf=1 with the job's own confs and the jar off the system classpath, the run logs 213 ClassCastExceptions while every query still reports PASS and run-it.sh exits 0. Add an assert-no-classcastexception input that greps the run log and fails the job, and tee the run output so it can be inspected. Verified locally at sf=1 against dev/tpcds_1g with the job's exact configuration, q1,q2,q3, Spark 4.1.2 / Scala 2.13 / JDK 17, Auron supplied only through --jars: without the fix in f9b49c1a the run logs 213 ClassCastExceptions and the new assertion exits 1; with the fix it logs 0 and the assertion exits 0. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…xplicit class loader NativeConverters.deserializeExpression built a plain ObjectInputStream, whose default resolveClass resolves each class through VM.latestUserDefinedLoader() -- a loader selected from the live call stack rather than the context class loader. During a nested read the most recent user-defined frame is often a Spark or Scala class, whose loader cannot see Auron classes when Auron is supplied through spark.jars and therefore loaded by MutableURLClassLoader. The expression graph then resolves only partially and an un-readResolve'd DefaultSerializationProxy is assigned into RDD.dependencies_, raising: java.lang.ClassCastException: cannot assign instance of scala.collection.generic.DefaultSerializationProxy to field org.apache.spark.rdd.RDD.dependencies_ of type scala.collection.immutable.Seq in instance of org.apache.spark.rdd.MapPartitionsRDD This also explains why the crash never reproduced in-process: in a test session Auron sits on the application class loader, which can always see its own classes, so the call-stack loader selection is harmless there. Pin resolution to an explicit loader -- the context/Spark loader, falling back to the loader that defined Auron, then to the default -- so class resolution no longer depends on which frame happens to be on the stack. Spark's own JavaDeserializationStream does the same; it is not reused here because it is private[spark] and Auron builds against eight Spark versions. Verified on TPC-DS with Spark 4.1.2 / Scala 2.13 / JDK 17, sf=10 with spark.sql.optimizer.runtime.bloomFilter.applicationSideScanSizeThreshold=1GB and Auron supplied through --jars. A controlled A/B over identical builds differing only in this change gives 42 ClassCastExceptions before and 0 after; q1, q2 and q3 pass 3/3 with results validated against vanilla Spark. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
686c5b2 to
af806de
Compare
|
Hi @ShreyeshArangath, could you please help to review this PR? This PR fixes #2386. The problem is in The fix is to resolve the classes with an explicit class loader. Spark's own This PR also adds a TPC-DS CI job for the runtime bloom filter. The job fails on the commit before the fix, and passes with the fix. If you prefer, I can move the CI job into a separate PR. Thank you! |
Which issue does this PR close?
Closes #2386
Rationale for this change
This PR addresses the issue #2386, which reports a subquery serialization failure when the runtime bloom filter optimizer is enabled.
Root Cause Analysis:
Spark's
InjectRuntimeFilteroptimization rewrites eligible join filters into aBloomFilterMightContainexpression whose bloom-filter input is an execution-sideScalarSubquery.Auron converts the bloom-filter expression natively, including its
ExecSubqueryExpressionchild. InNativeConverters.convertExprWithFallback,Auron calls
prepareExecSubquery()to materialize the subquery result, but thenserializes the entire
ScalarSubqueryobject:Although the result has already been materialized, the
ScalarSubquerystill retains its physical plan. Java serialization therefore traverses the plan and its RDD lineage, includingMapPartitionsRDD.dependencies_. This over-capture is what places RDD objects in the payload at all, and it is a real defect in its own right.The over-capture alone, however, does not raise the exception. The failure comes from how that payload is read back. The serialized expression is later evaluated by
SparkScalarSubqueryWrapperExpr, which delegates to the JVM expression wrapper. The JVM deserializes it with a plainObjectInputStreaminNativeConverters.deserializeExpression(). The defaultObjectInputStream.resolveClassresolves every class throughVM.latestUserDefinedLoader(), which selects a class loader from the live call stack rather than from the thread context class loader. During a nested read the most recent user-defined frame is frequently a Spark or Scala class, whose loader cannot see Auron's classes when Auron is supplied throughspark.jarsand is therefore defined by Spark'sMutableURLClassLoader.The expression graph consequently resolves only partially. An un-
readResolvedscala.collection.generic.DefaultSerializationProxyis then assigned into theRDD.dependencies_: scala.collection.immutable.Seqfield, producing:This explains two things that were previously puzzling. First, the failure depends on how Auron is deployed, not on the query alone: supplying the jar through
--jarsfails, while placing the same jar in$SPARK_HOME/jars(so it is defined by the application class loader, which can always see it) succeeds with byte-identical input. Second, it explains why the crash could never be reproduced in-process: in aSharedSparkSessiontest Auron sits on the application class loader, so the call-stack loader selection is harmless there.DefaultSerializationProxyis specific to Scala 2.13 collections, so that is the shape the corruption takes on 2.13; the underlying class-resolution behaviour is not itself version-specific.Therefore the runtime bloom-filter optimization is only the trigger, and the plan over-capture is only the precondition that puts an RDD graph in the payload. The exception itself is raised by call-stack-dependent class resolution during deserialization. This is also distinct from Auron's ordinary unsupported-expression fallback. Those expressions are converted into shallow bound expression trees and do not retain a physical plan or RDD lineage.
Supporting evidence
The defect reproduces in CI. The job added by this PR supplies Auron only through
--jars(jar-on-system-classpath: false) and asserts noClassCastException. On the commit preceding the fix, all ten TPC-DS shards fail with the exact exception above, raised through the JNI upcall atnative-engine/datafusion-ext-exprs/src/spark_udf_wrapper.rs:97.Class resolution is the operative cause, isolated from everything else. Replaying one captured payload in a single JVM, with identical bytes, identical thread context class loader, and only the
resolveClasspolicy varying:resolveClasspolicylatestUserDefinedLoader())ClassCastExceptionThe default policy produces a third, distinct outcome that matches neither pinned policy — the signature of a loader that varies per call site.
Caching and object-graph shape are ruled out. A ten-order operation matrix run across fresh JVMs against two independently captured payloads is completely order-independent, which excludes
ObjectStreamClass/field-reflector cache effects. The RDD graphs captured under both deployments are byte-identical (SHA-2561178bc4a6af500b29e7c26503ff28d8cd538124bd497ecf1b54d6e846ee2d5f0), and cross-replay shows the outcome follows the reading environment rather than the payload writer.What changes are included in this PR?
NativeConverters.deserializeExpression: resolve classes against an explicit class loader (the context/Spark loader, falling back to the loader that defined Auron) instead of relying onVM.latestUserDefinedLoader(), so deserialization no longer depends on which frame happens to be on the call stack. Spark's ownJavaDeserializationStreamdoes the same; it is not reused directly because it isprivate[spark]and Auron builds against eight Spark versions. Note that Spark additionally overridesresolveProxyClass; that is deliberately not mirrored here, because the only non-deprecated way to obtain a proxyClassisProxy.getProxyClassand the serialized expression graphs contain no dynamic proxies.ScalarSubqueryinconvertExprWithFallbackby serializing only its materializedLiteralvalue. This removes the RDD over-capture and is worthwhile on correctness and payload-size grounds, but it is a separate change and is not required to fix this exception.Are there any user-facing changes?
No
How was this patch tested?
Via integration tests.
Test matrix
Every row runs the TPC-DS harness with Auron supplied only through
spark-submit --jars, so it is defined by Spark'sMutableURLClassLoader. Environment is Spark 4.1 / Scala 2.13 / JDK 17. "Without the fix" always means a build of the commit preceding the fix on this branch, never a released Auron jar (see the note below).1BClassCastException1BClassCastException1BClassCastException1GBClassCastException1BRow 1 is the primary regression guard. The CI job fails on the commit before the fix and passes on it, on the same runners and the same sf=1 dataset.
Rows with a pass compare query output against vanilla Spark through the harness, so they are correctness passes rather than merely the absence of an exception.
One trap worth flagging for anyone reproducing this. The released
8.0.0-incubatingAuron jar does not reproduce the failure on the same dataset and configuration, so it is not a usable "before" baseline; an earlier revision of this description drew the wrong conclusion from it. Build the parent commit of the fix from this branch instead. The defect reproduces locally at sf=1 on an ordinary developer machine once the baseline is built correctly, and it does not depend on the Spark patch release, the dataset scale, or the number of cores.Deterministic payload replay
The query runs above depend on scheduling and plan shape. To remove that variability, the two scalar-subquery payloads captured from a failing run were also replayed directly through
NativeConverters.deserializeExpression, in a JVM where Auron is loaded by aMutableURLClassLoaderexactly asspark-submit --jarsarranges it.Each payload was replayed twice, once with the thread context class loader set to the Auron loader and once with it set to the parent, giving four combinations. All four raise the
ClassCastExceptionwithout the fix and all four succeed with it. This is fully deterministic and needs no TPC-DS data, which is what made it usable for the isolation experiment in the Supporting evidence section above.Exact local setup
To observe the failure rather than the fix, build and run the same command against the commit preceding the fix on this branch. Do not substitute a released Auron jar for that baseline; as noted above,
8.0.0-incubatingdoes not reproduce the failure.Was this patch authored or co-authored using generative AI tooling?
If yes, include:
Generated-by: <tool name and version>Generated-by: Claude Code (Claude Opus 5)
ASF guidance: https://www.apache.org/legal/generative-tooling.html