Skip to content

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

Description

@xiaoyanxie

Describe the bug

On Spark 4.1 (Scala 2.13), queries fail at runtime whenever Spark's runtime bloom filter optimization (InjectRuntimeFilter) is applied. Every affected task dies with:

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
    at java.base/java.io.ObjectStreamClass$FieldReflector.setObjFieldValues(ObjectStreamClass.java:2096)
    ...
    at org.apache.spark.sql.auron.NativeConverters$.deserializeExpression(NativeConverters.scala:1495)
    at org.apache.auron.spark.sql.SparkAuronUDFWrapperContext.<init>(SparkAuronUDFWrapperContext.scala:44)
    at org.apache.auron.jni.SparkAuronAdaptor.getAuronUDFWrapperContext(SparkAuronAdaptor.java:103)
    at org.apache.auron.jni.JniBridge.getAuronUDFWrapperContext(JniBridge.java:113)

followed by a native panic (output_with_sender[Filter]: output() returns error: External error: Java exception thrown at native-engine/datafusion-ext-exprs/src/spark_udf_wrapper.rs:97) and job abort.

Root cause (verified, see repro below):

  1. InjectRuntimeFilter rewrites join filters into might_contain(scalar-subquery(bloom_agg), xxhash64(key)). Auron converts BloomFilterMightContain natively (ShimsImpl.convertBloomFilterMightContain), and its bloomFilterExpression — an ExecSubqueryExpression — is converted at NativeConverters.scala:462, which Java-serializes the entire execution-side ScalarSubquery object, including its plan: BaseSubqueryExec reference. An executed plan transitively references RDDs (MapPartitionsRDD in the trace above).
  2. On the native side, SparkScalarSubqueryWrapperExpr evaluates by delegating to SparkUDFWrapperExpr (native-engine/datafusion-ext-exprs/src/spark_scalar_subquery_wrapper.rs), which calls back into the JVM and deserializes the bytes with a plain java.io.ObjectInputStream (NativeConverters.deserializeExpression). This is why the stack shows SparkAuronUDFWrapperContext although no UDF fallback is involved.
  3. On Scala 2.13, immutable collections serialize through scala.collection.generic.DefaultSerializationProxy, which is only replaced by the real collection in readResolve(). Per the Java serialization spec, readResolve substitution does not propagate to back-references, so in a cyclic graph (RDD lineage) the unresolved proxy gets assigned into RDD.dependencies_ClassCastException. Scala 2.12 has no serialization proxies for these collections, which is why the identical graph deserializes fine on 2.12.

Note this is not the SparkUDFWrapper expression-fallback path: ordinary fallback expressions (e.g. UnscaledValue / MakeDecimal, 167 instances in these runs) serialize shallow bound expression trees with no plan references and round-trip fine on Scala 2.13.

To Reproduce

Minimal repro (small dataset, threshold lowered so the runtime bloom filter is injected):

  1. Build Auron for Spark 4.1 / Scala 2.13 (./auron-build.sh --pre --sparkver 4.1 --scalaver 2.13).
  2. Generate TPC-DS parquet data at sf=10 (~5 GB).
  3. Run:
    SPARK_HOME=<spark-4.1.2-bin-hadoop3> SPARK_VERSION=spark-4.1 SCALA_VERSION=2.13 \
      dev/auron-it/run-it.sh --type tpcds --data-location <tpcds_10g> \
        --conf spark.sql.optimizer.runtime.bloomFilter.applicationSideScanSizeThreshold=1GB \
        --query-filter q1,q2,q3
    
  4. q2 and q3 fail with the exception above (the Auron run produces 0 rows, reported as a result mismatch vs the vanilla Spark baseline). q1 passes — its scanned tables stay below even the lowered threshold, so no runtime filter is injected there.

With default Spark settings the bug reproduces at sf=100, where the relevant fact-table scans cross the default spark.sql.optimizer.runtime.bloomFilter.applicationSideScanSizeThreshold (10GB):

per-table scan size (uncompressed parquet) sf=30 sf=60 sf=100
store_sales (q3 application side) 4.7 GB 9.5 GB 16 GB
catalog_sales (q2 application side) 3.3 GB 6.9 GB 12 GB

Verification matrix (Spark 4.1.2 / Scala 2.13, same host, JDK 21; CCE = ClassCastException count in log):

sf=1 sf=10 sf=10, threshold=1GB sf=30 sf=60 sf=100 sf=100, bloomFilter.enabled=false
result PASS 3/3 PASS 3/3 FAIL q2,q3 PASS 3/3 PASS 3/3 FAIL q2,q3 PASS 3/3
CCE 0 0 83 0 0 52 0

Spark 3.5.8 / Scala 2.12 passes 3/3 at every scale factor with default settings (same code path serializes the same plan-carrying subquery, but Scala 2.12 collections have no serialization proxies).

The two toggles isolate the cause in both directions: disabling the runtime bloom filter at sf=100 eliminates the crash with everything else unchanged (including all 167 fallback-expression serializations), and lowering the threshold at sf=10 introduces it.

Independently re-verified on a second machine/OS (sf=10, threshold=1GB case): macOS aarch64, JDK 17.0.16, Scala 2.13, Spark 4.1.2, Auron 8.0.0-incubating built from source (./auron-build.sh --pre --sparkver 4.1 --scalaver 2.13). Result: q1 PASS, q2 FAIL, q3 FAIL, 66 ClassCastException occurrences, 25 ordinary fallback-expression serializations (UnscaledValue/MakeDecimal) — same qualitative signature as the Linux/JDK21 run (CCE count differs, as expected, since partitioning/task counts differ across environments/runs). The captured stack matches frame-for-frame:

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
    ...
    at org.apache.spark.sql.auron.NativeConverters$.read$1(NativeConverters.scala:1498)
    at org.apache.spark.sql.auron.NativeConverters$.$anonfun$deserializeExpression$4(NativeConverters.scala:1506)
    at org.apache.spark.sql.auron.NativeConverters$.deserializeExpression(NativeConverters.scala:1495)
    at org.apache.auron.spark.sql.SparkAuronUDFWrapperContext.<init>(SparkAuronUDFWrapperContext.scala:44)
    at org.apache.auron.jni.SparkAuronAdaptor.getAuronUDFWrapperContext(SparkAuronAdaptor.java:103)
    at org.apache.auron.jni.JniBridge.getAuronUDFWrapperContext(JniBridge.java:113)

thread 'auron-native-stage-141-part-0-tid-251' panicked at native-engine/datafusion-ext-plans/src/common/execution_context.rs:573:21:
output_with_sender[Filter]: output() returns error: External error: Java exception thrown at native-engine/datafusion-ext-exprs/src/spark_udf_wrapper.rs:97: java.lang.ClassCastException: ...

This rules out a JDK-version-specific or OS/arch-specific explanation: the bug reproduces identically on Linux x86_64/JDK21 and macOS aarch64/JDK17, consistent with the root cause being purely about the Scala 2.13 collections runtime (DefaultSerializationProxy), not the JVM or platform.

Expected behavior

Queries with runtime-bloom-filter-injected scalar subqueries should run correctly on Spark 4.1 / Scala 2.13, as they do on Spark 3.5 / Scala 2.12. TPC-DS q2/q3 should pass the consistency check at sf=100 with default Spark settings.

Screenshots

N/A (full logs available on request).

Additional context

Metadata

Metadata

Assignees

Labels

No labels
No labels

Type

No type

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions