This is an automated email from the ASF dual-hosted git repository.

asf-gitbox-commits pushed a commit to branch branch-4.0
in repository https://gitbox.apache.org/repos/asf/spark.git


The following commit(s) were added to refs/heads/branch-4.0 by this push:
     new f25c849381f9 [SPARK-57371][CONNECT] Fix data race in ArrowDeserializers
f25c849381f9 is described below

commit f25c849381f99aefad4fbdc510cd12933f8d3909
Author: Haiyang Sun <[email protected]>
AuthorDate: Thu Jun 18 15:59:58 2026 -0400

    [SPARK-57371][CONNECT] Fix data race in ArrowDeserializers
    
    ### What changes were proposed in this pull request?
    
    Serialize the Scala runtime-reflection calls in `ArrowDeserializers` so 
they are safe under concurrent Arrow deserialization.
    
    While building deserializers, `ArrowDeserializers` uses runtime reflection 
in two places:
    - resolving a Scala collection's **companion object** 
(`mirror.classSymbol(cls).companion.asModule`) to construct the collection's 
builder;
    - resolving a Scala **`Enumeration`'s module instance** 
(`mirror.classSymbol(parent).module.asModule`) to deserialize enum values by 
name.
    
    Both now go through a `synchronized` seam on the `ArrowDeserializers` 
object monitor, with a consistent two-method shape:
    - `resolveCompanion(tag)` -> `resolveCompanionFromMirror(mirror, cls)` 
*(synchronized)*
    - `resolveEnum(parent)` -> `resolveEnumFromMirror(mirror, parent)` 
*(synchronized)*
    
    The thin outer methods read `currentMirror`; the inner `*FromMirror` 
methods take the mirror as a parameter and hold the lock around the reflection. 
Taking the mirror as a parameter lets the regression test drive these exact 
synchronized methods against a deliberately cold mirror, where the race 
reproduces. Both methods lock the same monitor, so the two reflection paths 
also serialize against each other.
    
    ### Why are the changes needed?
    
    Scala runtime reflection is not thread-safe (scala/bug#6240). When several 
Arrow result batches are deserialized concurrently against a still-cold 
reflection symbol table, `classSymbol(...).companion` / `.module` can observe 
the symbol as `NoSymbol`, so `.asModule` fails with:
    
    ```
    scala.ScalaReflectionException: <none> is not a module
    ```
    
    The window is normally narrow -- a mirror warms up after the first 
resolution of each symbol -- but it is reachable in practice when multiple 
deserializers resolve collection/enum types at the same time. Serializing the 
reflection through one monitor closes it.
    
    ### Does this PR introduce _any_ user-facing change?
    
    No. This is an internal robustness fix; the result of a successful 
deserialization is unchanged.
    
    ### How was this patch tested?
    
    New `ArrowDeserializersConcurrencySuite` reproduces the race with high 
probability: each repetition deterministically re-opens the race window by 
building a runtime mirror over a fresh, cold `URLClassLoader` (parented at the 
platform loader so `scala.*` is reloaded cold), then drives the real 
synchronized reflection from 16 threads x 50 repetitions, covering both 
`resolveCompanionFromMirror` (~40 collection companions) and 
`resolveEnumFromMirror` (`Enumeration` fixtures). Both tests pass.
    
    Validated end-to-end by toggling the fix:
    - **With the fix** -- the concurrency suite is green.
    - **Without the fix** (`synchronized` removed) -- the suite goes red with 
`scala.ScalaReflectionException: <none> is not a module`, the production 
symptom.
    - **No functional regression** -- `ArrowEncoderSuite` stays green.
    
    ### Was this patch authored or co-authored using generative AI tooling?
    YES
    
    Closes #56435 from haiyangsun-db/SPARK-57371.
    
    Authored-by: Haiyang Sun <[email protected]>
    Signed-off-by: Herman van Hövell <[email protected]>
    (cherry picked from commit fff25e5c43fe65afc4a3f462bc410a571e497edf)
    Signed-off-by: Herman van Hövell <[email protected]>
---
 .../connect/client/arrow/ArrowEncoderSuite.scala   | 75 ++++++++++++++++++++++
 .../connect/client/arrow/ArrowDeserializer.scala   | 52 +++++++++++++--
 2 files changed, 120 insertions(+), 7 deletions(-)

diff --git 
a/sql/connect/client/jvm/src/test/scala/org/apache/spark/sql/connect/client/arrow/ArrowEncoderSuite.scala
 
b/sql/connect/client/jvm/src/test/scala/org/apache/spark/sql/connect/client/arrow/ArrowEncoderSuite.scala
index 75816a835aaa..6339659a6b0b 100644
--- 
a/sql/connect/client/jvm/src/test/scala/org/apache/spark/sql/connect/client/arrow/ArrowEncoderSuite.scala
+++ 
b/sql/connect/client/jvm/src/test/scala/org/apache/spark/sql/connect/client/arrow/ArrowEncoderSuite.scala
@@ -16,15 +16,20 @@
  */
 package org.apache.spark.sql.connect.client.arrow
 
+import java.io.File
 import java.math.BigInteger
+import java.net.URLClassLoader
 import java.time.{Duration, Period, ZoneOffset}
 import java.time.temporal.ChronoUnit
 import java.util
 import java.util.{Collections, Objects}
+import java.util.concurrent.{ConcurrentLinkedQueue, CyclicBarrier}
 
 import scala.beans.BeanProperty
 import scala.collection.mutable
+import scala.jdk.CollectionConverters._
 import scala.reflect.classTag
+import scala.reflect.runtime.{universe => ru}
 
 import org.apache.arrow.memory.{BufferAllocator, RootAllocator}
 import org.apache.arrow.vector.VarBinaryVector
@@ -1023,6 +1028,76 @@ class ArrowEncoderSuite extends ConnectFunSuite with 
BeforeAndAfterAll {
       }
     }
   }
+
+  // SPARK-57371: ArrowDeserializers resolves Scala collection companions and 
Enumeration modules
+  // via runtime reflection, which is not thread-safe (scala/bug#6240): a 
concurrent
+  // `mirror.classSymbol(cls).companion/.module.asModule` can observe the 
symbol as `NoSymbol` and
+  // throw `ScalaReflectionException: <none> is not a module`. 
ArrowDeserializers serializes the
+  // reflection through a single monitor. The race only manifests while a 
mirror's symbol table is
+  // cold, so each repetition below builds a fresh mirror over a classloader 
parented at the
+  // platform loader (so `scala.*` is reloaded cold) and drives the real 
synchronized method from
+  // several threads released at once; without the lock it races red.
+
+  private val collectionCompanionClassNames = Seq(
+    "scala.collection.immutable.List",
+    "scala.collection.immutable.Vector",
+    "scala.collection.immutable.Set",
+    "scala.collection.immutable.Map",
+    "scala.collection.mutable.ArrayBuffer",
+    "scala.collection.mutable.HashMap")
+
+  /** A fresh classloader parented at the platform loader, so `scala.*` is 
reloaded cold. */
+  private def newColdLoader(): URLClassLoader = {
+    val urls = System
+      .getProperty("java.class.path")
+      .split(File.pathSeparator)
+      .filter(_.nonEmpty)
+      .map(p => new File(p).toURI.toURL)
+    new URLClassLoader(urls, ClassLoader.getPlatformClassLoader)
+  }
+
+  // Drive `resolve` against a fresh cold mirror from 8 threads, 50 times; 
fail on any error/hang.
+  private def hammerReflection(
+      names: Seq[String],
+      resolve: (ru.Mirror, Class[_]) => Any): Unit = {
+    val errors = new ConcurrentLinkedQueue[Throwable]()
+    for (_ <- 0 until 50) {
+      val loader = newColdLoader()
+      val mirror = ru.runtimeMirror(loader)
+      val classes = names.map(loader.loadClass)
+      val barrier = new CyclicBarrier(8)
+      val threads = (0 until 8).map { _ =>
+        new Thread(() => {
+          barrier.await() // release all threads simultaneously onto the cold 
mirror
+          classes.foreach { cls =>
+            try resolve(mirror, cls)
+            catch { case e: Throwable => errors.add(e) }
+          }
+        })
+      }
+      threads.foreach(_.start())
+      threads.foreach { t =>
+        t.join(60000)
+        assert(!t.isAlive, "thread did not finish within 60s (possible 
deadlock)")
+      }
+    }
+    assert(
+      errors.isEmpty,
+      s"reflection raced under concurrent access (${errors.size} error(s)): " +
+        errors.asScala.map(e => s"${e.getClass.getName}: 
${e.getMessage}").toSet.mkString("; "))
+  }
+
+  test("SPARK-57371: resolveCompanion is thread-safe under concurrent 
cold-mirror access") {
+    hammerReflection(
+      collectionCompanionClassNames,
+      (m, c) => ArrowDeserializers.resolveCompanionFromMirror(m, c))
+  }
+
+  test("SPARK-57371: resolveEnum is thread-safe under concurrent cold-mirror 
access") {
+    hammerReflection(
+      Seq(FooEnum.getClass.getName),
+      (m, c) => ArrowDeserializers.resolveEnumFromMirror(m, c))
+  }
 }
 
 // TODO fix actual Null fields, e.g.: nullable: Null
diff --git 
a/sql/connect/common/src/main/scala/org/apache/spark/sql/connect/client/arrow/ArrowDeserializer.scala
 
b/sql/connect/common/src/main/scala/org/apache/spark/sql/connect/client/arrow/ArrowDeserializer.scala
index ceeece073da6..7169fa249b2c 100644
--- 
a/sql/connect/common/src/main/scala/org/apache/spark/sql/connect/client/arrow/ArrowDeserializer.scala
+++ 
b/sql/connect/common/src/main/scala/org/apache/spark/sql/connect/client/arrow/ArrowDeserializer.scala
@@ -140,9 +140,9 @@ object ArrowDeserializers {
           }
         }
       case (ScalaEnumEncoder(parent, _), v: FieldVector) =>
-        val mirror = scala.reflect.runtime.currentMirror
-        val module = mirror.classSymbol(parent).module.asModule
-        val enumeration = 
mirror.reflectModule(module).instance.asInstanceOf[Enumeration]
+        // Scala runtime reflection is not thread-safe (scala/bug#6240). 
Synchronize to
+        // prevent races that surface as "... is not a module" under 
concurrent access.
+        val enumeration = resolveEnum(parent)
         new LeafFieldDeserializer[Enumeration#Value](encoder, v, timeZoneId) {
           override def value(i: Int): Enumeration#Value = {
             enumeration.withName(reader.getString(i))
@@ -409,11 +409,49 @@ object ArrowDeserializers {
   /**
    * Resolve the companion object for a scala class. In our particular case 
the class we pass in
    * is a Scala collection. We use the companion to create a builder for that 
collection.
+   *
+   * Scala runtime reflection is not thread-safe (scala/bug#6240): concurrent 
calls to
+   * `classSymbol(...).companion` can race, leaving the companion as 
`NoSymbol` so that
+   * `.asModule` throws `ScalaReflectionException: <none> is not a module`. We 
serialize the
+   * reflection through this object's monitor (see 
[[resolveCompanionFromMirror]]) to prevent it.
+   */
+  private[arrow] def resolveCompanion[T](tag: ClassTag[_]): T =
+    resolveCompanionFromMirror(scala.reflect.runtime.currentMirror, 
tag.runtimeClass)
+      .asInstanceOf[T]
+
+  /**
+   * Synchronized reflection to resolve a companion object. The mirror is 
passed in rather than
+   * read from `currentMirror` so that the concurrency regression test can 
drive this exact
+   * (synchronized) method against a deliberately cold mirror, where the race 
would otherwise
+   * surface. Production always passes `currentMirror`.
+   */
+  private[arrow] def resolveCompanionFromMirror(
+      mirror: scala.reflect.runtime.universe.Mirror,
+      cls: Class[_]): Any = synchronized {
+    val module = mirror.classSymbol(cls).companion.asModule
+    mirror.reflectModule(module).instance
+  }
+
+  /**
+   * Resolve a Scala Enumeration parent class to its module instance. Reads 
`currentMirror` and
+   * delegates to [[resolveEnumFromMirror]], mirroring the 
[[resolveCompanion]] /
+   * [[resolveCompanionFromMirror]] split.
+   */
+  private def resolveEnum(parent: Class[_]): Enumeration =
+    resolveEnumFromMirror(scala.reflect.runtime.currentMirror, 
parent).asInstanceOf[Enumeration]
+
+  /**
+   * Synchronized reflection to resolve a Scala Enumeration's module instance. 
As with
+   * [[resolveCompanionFromMirror]], the mirror is passed in rather than read 
from `currentMirror`
+   * so the concurrency regression test can drive this exact (synchronized) 
method against a
+   * deliberately cold mirror. Synchronized on the same monitor as 
[[resolveCompanionFromMirror]]
+   * for the same thread-safety reasons. Production always passes 
`currentMirror`.
    */
-  private[arrow] def resolveCompanion[T](tag: ClassTag[_]): T = {
-    val mirror = scala.reflect.runtime.currentMirror
-    val module = mirror.classSymbol(tag.runtimeClass).companion.asModule
-    mirror.reflectModule(module).instance.asInstanceOf[T]
+  private[arrow] def resolveEnumFromMirror(
+      mirror: scala.reflect.runtime.universe.Mirror,
+      parent: Class[_]): Any = synchronized {
+    val module = mirror.classSymbol(parent).module.asModule
+    mirror.reflectModule(module).instance
   }
 
   /**


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

Reply via email to