Skip to content

[AURON #2386] Resolve deserialized expression classes with an explicit class loader - #2395

Open
xiaoyanxie wants to merge 3 commits into
apache:masterfrom
xiaoyanxie:fix/2386-runtime-bloom-filter-subquery-serialization
Open

[AURON #2386] Resolve deserialized expression classes with an explicit class loader#2395
xiaoyanxie wants to merge 3 commits into
apache:masterfrom
xiaoyanxie:fix/2386-runtime-bloom-filter-subquery-serialization

Conversation

@xiaoyanxie

@xiaoyanxie xiaoyanxie commented Jul 19, 2026

Copy link
Copy Markdown

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 InjectRuntimeFilter optimization rewrites eligible join filters into a
BloomFilterMightContain expression whose bloom-filter input is an execution-side

ScalarSubquery.
Auron converts the bloom-filter expression natively, including its ExecSubqueryExpression child. In NativeConverters.convertExprWithFallback,
Auron calls prepareExecSubquery() to materialize the subquery result, but then
serializes the entire ScalarSubquery object:

case subquery: ExecSubqueryExpression =>
  prepareExecSubquery(subquery)
  val serialized = serializeExpression(
    subquery.asInstanceOf[Expression with Serializable],
    StructType(Nil))

Although the result has already been materialized, the ScalarSubquery still retains its physical plan. Java serialization therefore traverses the plan and its RDD lineage, including MapPartitionsRDD.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 plain ObjectInputStream in NativeConverters.deserializeExpression(). The default ObjectInputStream.resolveClass resolves every class through VM.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 through spark.jars and is therefore defined by Spark's MutableURLClassLoader.

The expression graph consequently resolves only partially. An un-readResolved scala.collection.generic.DefaultSerializationProxy is then assigned into the RDD.dependencies_: scala.collection.immutable.Seq field, producing:

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 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 --jars fails, 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 a SharedSparkSession test Auron sits on the application class loader, so the call-stack loader selection is harmless there.

DefaultSerializationProxy is 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 no ClassCastException. On the commit preceding the fix, all ten TPC-DS shards fail with the exact exception above, raised through the JNI upcall at native-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 resolveClass policy varying:

resolveClass policy Result
pinned to the loader that defines Auron succeeds
pinned to the application class loader fails (cannot see Auron classes)
default (latestUserDefinedLoader()) fails with the reported ClassCastException

The 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-256 1178bc4a6af500b29e7c26503ff28d8cd538124bd497ecf1b54d6e846ee2d5f0), and cross-replay shows the outcome follows the reading environment rather than the payload writer.

What changes are included in this PR?

  • An integration test in the GitHub CI that reproduces the bug
  • A fix in NativeConverters.deserializeExpression: resolve classes against an explicit class loader (the context/Spark loader, falling back to the loader that defined Auron) instead of relying on VM.latestUserDefinedLoader(), so deserialization no longer depends on which frame happens to be on the call stack. Spark's own JavaDeserializationStream does the same; it is not reused directly because it is private[spark] and Auron builds against eight Spark versions. Note that Spark additionally overrides resolveProxyClass; that is deliberately not mirrored here, because the only non-deprecated way to obtain a proxy Class is Proxy.getProxyClass and the serialized expression graphs contain no dynamic proxies.
  • Follow-up, not included here: stop serializing the physical plan for ScalarSubquery in convertExprWithFallback by serializing only its materialized Literal value. 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's MutableURLClassLoader. 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).

# Where Data / bloom-filter threshold Queries Without the fix With the fix
1 GitHub CI, job added by this PR sf=1 / 1B all 10 shards 10/10 shards fail with the ClassCastException 11/11 jobs pass, CCE assertion green in all 10 shards
2 Local, Spark 4.1.2 sf=1 / 1B q1..q9 1515 ClassCastException 0
3 Local, Spark 4.1.3 sf=1 / 1B q1..q9 850 ClassCastException 0
4 Local sf=10 / 1GB q2 42 ClassCastException 0
5 Local sf=10 / 1B q1..q9 not run 9/9 pass, 0

Row 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-incubating Auron 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 a MutableURLClassLoader exactly as spark-submit --jars arranges 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 ClassCastException without 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

# 1. Build the Auron jar for Spark 4.1 / Scala 2.13
./auron-build.sh --release --sparkver 4.1 --scalaver 2.13

# 2. Build the integration-test jar
#    (-Dscalafix.skip=true works around a pre-existing scalafix failure in this module)
cd dev/auron-it && ../../build/mvn -Pspark-4.1 -Pscala-2.13 -DskipTests -Dscalafix.skip=true package && cd ../..

# 3. TPC-DS data. This is the same sf=1 set CI uses, and it reproduces the
#    failure locally when the baseline is built from this branch.
git clone --depth 1 https://github.com/auron-project/tpcds_1g dev/tpcds_1g

# 4. Point at a Spark 4.1 distribution.
#    IMPORTANT: do NOT copy the Auron jar into $SPARK_HOME/jars. The defect only
#    appears when Auron is defined by MutableURLClassLoader rather than by the
#    application class loader.
export SPARK_HOME=/path/to/spark-4.1.2-bin-hadoop3

# 5. Run, mirroring the CI job's configuration
SPARK_VERSION=spark-4.1 SCALA_VERSION=2.13 \
AURON_SPARK_JAR=dev/mvn-build-helper/assembly/target/auron-spark-4.1_2.13-<version>.jar \
dev/auron-it/run-it.sh \
  --type tpcds \
  --data-location dev/tpcds_1g \
  --conf spark.sql.optimizer.runtime.bloomFilter.enabled=true \
  --conf spark.sql.optimizer.runtime.bloomFilter.applicationSideScanSizeThreshold=1B \
  --conf spark.sql.autoBroadcastJoinThreshold=-1 \
  --query-filter q1,q2,q3,q4,q5,q6,q7,q8,q9

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-incubating does not reproduce the failure.

Was this patch authored or co-authored using generative AI tooling?

  • Yes
  • No

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

@github-actions github-actions Bot added the infra label Jul 19, 2026
@xiaoyanxie
xiaoyanxie force-pushed the fix/2386-runtime-bloom-filter-subquery-serialization branch 2 times, most recently from fd757e0 to 6387ca9 Compare August 4, 2026 10:10
@xiaoyanxie

Copy link
Copy Markdown
Author

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.

@xiaoyanxie xiaoyanxie changed the title Try to reproduce the issue in the GitHub CI pipeline [AURON #2386] Resolve deserialized expression classes with an explicit class loader Aug 4, 2026
@github-actions github-actions Bot added the spark label Aug 4, 2026
xiaoyanxie and others added 3 commits August 4, 2026 14:07
… 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>
@xiaoyanxie
xiaoyanxie force-pushed the fix/2386-runtime-bloom-filter-subquery-serialization branch from 686c5b2 to af806de Compare August 4, 2026 21:11
@xiaoyanxie
xiaoyanxie marked this pull request as ready for review August 4, 2026 21:43
@xiaoyanxie

Copy link
Copy Markdown
Author

Hi @ShreyeshArangath, could you please help to review this PR?

This PR fixes #2386. The problem is in NativeConverters.deserializeExpression. It used a plain ObjectInputStream. The default resolveClass resolves each class by VM.latestUserDefinedLoader(), which selects a class loader from the call stack. When Auron is provided by spark.jars, that loader cannot see the Auron classes. So the scalar subquery graph is only partly deserialized, and a ClassCastException is thrown.

The fix is to resolve the classes with an explicit class loader. Spark's own JavaDeserializationStream does the same thing.

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!

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

ClassCastException (Scala 2.13 DefaultSerializationProxy) when deserializing ScalarSubquery injected by runtime bloom filter on Spark 4.1

1 participant