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

dongjoon-hyun pushed a commit to branch branch-4.x
in repository https://gitbox.apache.org/repos/asf/spark.git


The following commit(s) were added to refs/heads/branch-4.x by this push:
     new 7f923c25bbb8 [SPARK-59884][SQL] Mark SparkPlan's columnar execution 
cache transient
7f923c25bbb8 is described below

commit 7f923c25bbb8c806c156b461f4645d9a15116779
Author: Chao Sun <[email protected]>
AuthorDate: Tue Oct 6 12:27:02 2026 -0700

    [SPARK-59884][SQL] Mark SparkPlan's columnar execution cache transient
    
    ### What changes were proposed in this pull request?
    
    Mark `SparkPlan.executeColumnarRDD` transient, matching the row execution 
cache, and correct the `executeColumnar()` Scaladoc's references to 
`doExecuteColumnar()`.
    
    Add two SPARK-59884 regression tests:
    - A direct plan serialization round trip with an initialized columnar cache 
containing a nonserializable sentinel, also checking driver-side cache reuse.
    - A vectorized Parquet aggregation across a shuffle. The test checks the 
query result and verifies that the job used the scan's columnar wrapper RDD. It 
then serializes the downstream result-stage RDD/function tuple, asserts that 
serialization visits the upstream scan plan, and checks that the wrapper RDD is 
absent.
    
    JIRA: [SPARK-59884](https://issues.apache.org/jira/browse/SPARK-59884)
    
    ### Why are the changes needed?
    
    Plans captured by downstream operators can retain upstream plans across 
shuffle boundaries. An initialized `executeColumnarRDD` cache can therefore 
serialize an RDD that is outside the downstream stage's input lineage. Making 
this driver-side cache transient removes that reference while preserving 
driver-side reuse.
    
    For the Parquet regression, this excludes only the columnar wrapper RDD. 
`FileSourceScanExec.inputRDD` and other scan-owned caches remain separate 
retention paths. `AQEShuffleReadExec.shuffleRDD`, the broadcast execution 
cache, and whole-plan capture are also outside this PR's scope.
    
    The cache was introduced by [SPARK-48195 / 
#48037](https://github.com/apache/spark/pull/48037) in 
[d2e8c1c](https://github.com/apache/spark/commit/d2e8c1cb60e34a1c7e92374c07d682aa5ca79145),
 first included in Spark 4.0.0. Branches 4.0 through 4.3 contain the same 
nontransient field and are candidates for backporting.
    
    ### Does this PR introduce _any_ user-facing change?
    
    Yes. It avoids serializing an upstream columnar RDD through the base plan 
execution cache when that RDD is not needed by a downstream stage. Query 
results and public APIs are unchanged. It does not make a nonserializable RDD 
usable as a stage's actual input or eliminate other plan-owned RDD references.
    
    ### How was this patch tested?
    
    - Full `SparkPlanSuite`: 14 tests passed after compiling the changed files 
against CI-built Spark 5.0 dependencies.
    - Both SPARK-59884 regressions failed as expected when only the 
`executeColumnarRDD` transient annotation was removed.
    - Scoped Scalastyle and `git diff --check` passed.
    
    ### Was this patch authored or co-authored using generative AI tooling?
    
    Generated-by: Codex (GPT-6).
    
    Closes #59146 from sunchao/codex/columnar-rdd-transient-upstream.
    
    Authored-by: Chao Sun <[email protected]>
    Signed-off-by: Dongjoon Hyun <[email protected]>
    (cherry picked from commit 895c75f0032a93ac29dff18d6720783ba1f22a5c)
    Signed-off-by: Dongjoon Hyun <[email protected]>
---
 .../org/apache/spark/sql/execution/SparkPlan.scala |  5 +-
 .../spark/sql/execution/SparkPlanSuite.scala       | 81 +++++++++++++++++++++-
 2 files changed, 82 insertions(+), 4 deletions(-)

diff --git 
a/sql/core/src/main/scala/org/apache/spark/sql/execution/SparkPlan.scala 
b/sql/core/src/main/scala/org/apache/spark/sql/execution/SparkPlan.scala
index 4f8f66eb5969..6912f44a3452 100644
--- a/sql/core/src/main/scala/org/apache/spark/sql/execution/SparkPlan.scala
+++ b/sql/core/src/main/scala/org/apache/spark/sql/execution/SparkPlan.scala
@@ -218,15 +218,16 @@ abstract class SparkPlan extends QueryPlan[SparkPlan] 
with Logging with Serializ
     executeBroadcastBcast.get.asInstanceOf[broadcast.Broadcast[T]]
   }
 
+  @transient
   private val executeColumnarRDD = LazyTry {
     doExecuteColumnar()
   }
 
   /**
-   * Returns the result of this query as an RDD[ColumnarBatch] by delegating 
to `doColumnarExecute`
+   * Returns the result of this query as an RDD[ColumnarBatch] by delegating 
to `doExecuteColumnar`
    * after preparations.
    *
-   * Concrete implementations of SparkPlan should override `doColumnarExecute` 
if `supportsColumnar`
+   * Concrete implementations of SparkPlan should override `doExecuteColumnar` 
if `supportsColumnar`
    * returns true.
    */
   final def executeColumnar(): RDD[ColumnarBatch] = executeQuery {
diff --git 
a/sql/core/src/test/scala/org/apache/spark/sql/execution/SparkPlanSuite.scala 
b/sql/core/src/test/scala/org/apache/spark/sql/execution/SparkPlanSuite.scala
index c0f5ad61d72e..45a0f4098bc5 100644
--- 
a/sql/core/src/test/scala/org/apache/spark/sql/execution/SparkPlanSuite.scala
+++ 
b/sql/core/src/test/scala/org/apache/spark/sql/execution/SparkPlanSuite.scala
@@ -17,14 +17,16 @@
 
 package org.apache.spark.sql.execution
 
+import java.io.{ObjectOutputStream, OutputStream}
 import java.lang.management.ManagementFactory
 import java.util.concurrent.{CountDownLatch, TimeUnit}
 
 import scala.concurrent.{ExecutionContext, Future}
 import scala.concurrent.duration.Duration
 
-import org.apache.spark.{SparkEnv, SparkException, 
SparkUnsupportedOperationException}
-import org.apache.spark.rdd.RDD
+import org.apache.spark.{SparkEnv, SparkException, 
SparkUnsupportedOperationException, TaskContext}
+import org.apache.spark.rdd.{EmptyRDD, RDD}
+import org.apache.spark.sql.{QueryTest, Row}
 import org.apache.spark.sql.catalyst.InternalRow
 import org.apache.spark.sql.catalyst.expressions.{
   Attribute, AttributeReference, Expression, ExprId, Literal}
@@ -32,6 +34,7 @@ import 
org.apache.spark.sql.catalyst.expressions.codegen.{CodegenContext, ExprCo
 import org.apache.spark.sql.catalyst.plans.logical.Deduplicate
 import org.apache.spark.sql.catalyst.trees.LeafLike
 import org.apache.spark.sql.execution.datasources.v2.BatchScanExec
+import org.apache.spark.sql.execution.exchange.ShuffleExchangeExec
 import org.apache.spark.sql.internal.SQLConf
 import org.apache.spark.sql.test.SharedSparkSession
 import org.apache.spark.sql.types.{DataType, IntegerType, StringType}
@@ -40,6 +43,68 @@ import org.apache.spark.util.ThreadUtils
 
 class SparkPlanSuite extends SharedSparkSession {
 
+  test("SPARK-59884: columnar execution cache is not serialized with a plan") {
+    val source = ColumnarCacheSerializationSource(Seq(AttributeReference("id", 
IntegerType)()))
+    val rdd = source.executeColumnar()
+    assert(source.executeColumnar() eq rdd)
+    val serializer = SparkEnv.get.closureSerializer.newInstance()
+    // Plans can be captured by task closures, but their execution caches 
belong to the driver.
+    
serializer.deserialize[ColumnarToRowExec](serializer.serialize(ColumnarToRowExec(source)))
+  }
+
+  test("SPARK-59884: columnar execution cache does not cross a shuffle in the 
task binary") {
+    withSQLConf(
+      SQLConf.ADAPTIVE_EXECUTION_ENABLED.key -> "false",
+      SQLConf.WHOLESTAGE_CODEGEN_ENABLED.key -> "true",
+      SQLConf.USE_V1_SOURCE_LIST.key -> "parquet",
+      SQLConf.PARQUET_VECTORIZED_READER_ENABLED.key -> "true",
+      SQLConf.SHUFFLE_PARTITIONS.key -> "2") {
+      withTempPath { path =>
+        spark.range(0, 8, 1, 2).selectExpr("id % 2 AS 
key").write.parquet(path.getAbsolutePath)
+        val query = 
spark.read.parquet(path.getAbsolutePath).groupBy("key").count()
+        QueryTest.checkAnswer(query, Seq(Row(0L, 4L), Row(1L, 4L)), checkToRDD 
= false)
+
+        val plan = query.queryExecution.executedPlan
+        val exchanges = plan.collect { case exchange: ShuffleExchangeExec => 
exchange }
+        assert(exchanges.size == 1)
+        val scan = exchanges.head.child.collectFirst {
+          case scan: FileSourceScanExec => scan
+        }.get
+        assert(scan.supportsColumnar)
+        val columnarRDD = scan.executeColumnar()
+        def lineage(rdd: RDD[_]): Seq[RDD[_]] = {
+          rdd +: rdd.dependencies.flatMap(d => lineage(d.rdd))
+        }
+        assert(lineage(exchanges.head.inputRDD).exists(_ eq columnarRDD),
+          "the job must create the columnar RDD, or the test exercises 
nothing")
+        val stageRDD = plan.execute()
+
+        var serializedScan = false
+        var serializedColumnarRDD = false
+        val out = new ObjectOutputStream(OutputStream.nullOutputStream()) {
+          enableReplaceObject(true)
+          override protected def replaceObject(obj: AnyRef): AnyRef = {
+            serializedScan ||= obj eq scan
+            serializedColumnarRDD ||= obj eq columnarRDD
+            obj
+          }
+        }
+        try {
+          // DAGScheduler serializes the complete (stage.rdd, stage.func) 
tuple for a result task.
+          // The scan's columnar wrapper RDD is outside the downstream stage's 
input lineage.
+          // The captured plan must not reintroduce it through its columnar 
execution cache.
+          // FileSourceScanExec.inputRDD is a separate cache and is not 
covered by this fix.
+          val taskFunc = (_: TaskContext, rows: Iterator[InternalRow]) => 
rows.size
+          out.writeObject((stageRDD, taskFunc))
+        } finally {
+          out.close()
+        }
+        assert(serializedScan, "the downstream stage no longer captures the 
upstream plan")
+        assert(!serializedColumnarRDD, "upstream columnar RDD was captured 
across the shuffle")
+      }
+    }
+  }
+
   test("SPARK-21619 execution of a canonicalized plan should fail") {
     val plan = spark.range(10).queryExecution.executedPlan.canonicalized
 
@@ -268,6 +333,18 @@ case class ColumnarOp(child: SparkPlan) extends 
UnaryExecNode {
     copy(child = newChild)
 }
 
+case class ColumnarCacheSerializationSource(output: Seq[Attribute]) extends 
LeafExecNode {
+  override val supportsColumnar: Boolean = true
+
+  override protected def doExecuteColumnar(): RDD[ColumnarBatch] = {
+    new EmptyRDD[ColumnarBatch](sparkContext) {
+      val driverOnlyState: AnyRef = new Object
+    }
+  }
+
+  override protected def doExecute(): RDD[InternalRow] = throw 
SparkUnsupportedOperationException()
+}
+
 private case class TestSubqueryExec(child: SparkPlan) extends BaseSubqueryExec 
{
   override def name: String = "TestSubqueryExec"
   override def children: Seq[SparkPlan] = Seq(child)


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

Reply via email to