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

cloud-fan pushed a commit to branch branch-4.2
in repository https://gitbox.apache.org/repos/asf/spark.git


The following commit(s) were added to refs/heads/branch-4.2 by this push:
     new 37fae57a32bc [SPARK-57547][SQL] Fix incorrect InMemoryRelation 
materialization under conrrent queries
37fae57a32bc is described below

commit 37fae57a32bcee2e023d5f9d80409f6d1af99fa3
Author: Ziqi Liu <[email protected]>
AuthorDate: Mon Jun 22 13:37:26 2026 -0700

    [SPARK-57547][SQL] Fix incorrect InMemoryRelation materialization under 
conrrent queries
    
    ### What changes were proposed in this pull request?
    
    This change adds `PartitionKeyedAccumulator`, a `ConcurrentHashMap`-backed 
accumulator keyed by partition id with last-write-wins merge semantics and 
replaces the counter-based accumulators in `CachedRDDBuilder`(used in 
`InMemoryRelation`) with the new accumulator. The cached relation now:
      - counts the DISTINCT materialized partition ids (the accumulator key 
set) when deciding whether the cache is fully loaded, so duplicate computes 
cannot inflate the count; and
      - derives exact, de-duplicated row-count and size stats by folding the 
per-partition values, counting each partition once.
    
    The behavior is gated by a new internal conf
    `spark.sql.inMemoryColumnarStorage.distinctPartitionTracking` (default 
true); setting it to false restores the prior raw task-completion-count 
behavior. `clearCache` resets the bookkeeping so a rebuilt cache starts clean.
    
    ### Why are the changes needed?
    
    Fix the bug(introduced here https://github.com/apache/spark/pull/39624, 
seems like a day-1 bug) where InMemoryRelation will be marked materialized 
prematurely under conrrent queries:
    
    - AQE creates a separate `TableCacheQueryStageExec` for every reference to 
the same `df.cache` (never reused), and each one submits its own build job over 
the *shared cache RDD*.
    - When concurrent queries reference the same cached relation, first-touches 
the cold cache from several jobs at once. Spark has no global, cross-executor 
"compute this partition once" barrier (only a per-executor write lock), so the 
same partition can be computed by multiple executors. 
`CachedRDDBuilder.isCachedRDDLoaded` decided the cache was materialized by 
comparing the partition count against a *raw task-completion count*. Duplicate 
completions of an empty-output partition could p [...]
    - One situation that can result in incorrect results: 
`AQEPropagateEmptyRelation` then ("correctly", given the stats it was told) 
collapsed the cache branch to an `EmptyRelation` and silently dropping rows.
    - Additional latent bugs:
      - size/rows accumulators could be over-counted
      - no accumulators reset upon `clearCache`
    
    ### Does this PR introduce _any_ user-facing change?
    NO
    
    ### How was this patch tested?
    
    - `PartitionKeyedAccumulatorSuite` - accumulator semantics (last-write-wins 
add/merge, distinct key count, snapshot/reset).
    - `ConcurrentInMemoryRelationSuite` - local-cluster reproduction: rows are 
preserved under concurrent first-touch with the fix on; stats are exact under 
duplicate cross-executor computes; and a negative control showing the row loss 
with the fix disabled.
    - Extended `CachedTableSuite` (clearCache resets bookkeeping) and 
`InMemoryColumnarQuerySuite` (size/row-count read through the new accessors).
    
    ### Was this patch authored or co-authored using generative AI tooling?
    Yes
    
    Closes #56620 from liuzqt/SPARK-57547.
    
    Authored-by: Ziqi Liu <[email protected]>
    Signed-off-by: Wenchen Fan <[email protected]>
---
 .../sql/execution/columnar/InMemoryRelation.scala  |  71 ++++-
 .../spark/sql/util/PartitionKeyedAccumulator.scala |  90 ++++++
 .../org/apache/spark/sql/CachedTableSuite.scala    |  35 ++-
 .../columnar/ConcurrentInMemoryRelationSuite.scala | 325 +++++++++++++++++++++
 .../columnar/InMemoryColumnarQuerySuite.scala      |   4 +-
 .../sql/util/PartitionKeyedAccumulatorSuite.scala  | 107 +++++++
 6 files changed, 615 insertions(+), 17 deletions(-)

diff --git 
a/sql/core/src/main/scala/org/apache/spark/sql/execution/columnar/InMemoryRelation.scala
 
b/sql/core/src/main/scala/org/apache/spark/sql/execution/columnar/InMemoryRelation.scala
index 9c012dbd58e1..f79742907779 100644
--- 
a/sql/core/src/main/scala/org/apache/spark/sql/execution/columnar/InMemoryRelation.scala
+++ 
b/sql/core/src/main/scala/org/apache/spark/sql/execution/columnar/InMemoryRelation.scala
@@ -36,10 +36,11 @@ import 
org.apache.spark.sql.execution.adaptive.AdaptiveSparkPlanExec
 import org.apache.spark.sql.execution.vectorized.{OffHeapColumnVector, 
OnHeapColumnVector, WritableColumnVector}
 import org.apache.spark.sql.internal.{SQLConf, StaticSQLConf}
 import org.apache.spark.sql.types._
+import org.apache.spark.sql.util.PartitionKeyedAccumulator
 import org.apache.spark.sql.vectorized.{ColumnarBatch, ColumnVector}
 import org.apache.spark.storage.StorageLevel
-import org.apache.spark.util.{LongAccumulator, Utils}
 import org.apache.spark.util.ArrayImplicits._
+import org.apache.spark.util.Utils
 
 /**
  * The default implementation of CachedBatch.
@@ -261,9 +262,20 @@ case class CachedRDDBuilder(
   @transient @volatile private var _cachedColumnBuffers: RDD[CachedBatch] = 
null
   @transient @volatile private var _cachedColumnBuffersAreLoaded: Boolean = 
false
 
-  val sizeInBytesStats: LongAccumulator = 
cachedPlan.session.sparkContext.longAccumulator
-  val rowCountStats: LongAccumulator = 
cachedPlan.session.sparkContext.longAccumulator
-  private val materializedPartitions = 
cachedPlan.session.sparkContext.longAccumulator
+  // The cache's materialization bookkeeping: a partition-keyed accumulator 
storing
+  // (rowCount, sizeInBytes) per partition. AQE creates a separate cache scan 
stage per reference to
+  // the same cache and each submits its own build job, so the same partition 
can be computed by
+  // several concurrent jobs (and speculative tasks); Spark has no global 
cross-executor "compute
+  // this partition once" barrier (only a per-executor write lock). Keying by 
partition id
+  // (last-write-wins) means those duplicate completions cannot mark the cache 
loaded before every
+  // partition has been computed -- which otherwise let AQE read rowCount 0 on 
a non-empty cache and
+  // propagate an empty relation, silently dropping rows -- and also yields 
exact, de-duplicated row
+  // count / size.
+  private val partitionStats: PartitionKeyedAccumulator[(Long, Long)] = {
+    val acc = new PartitionKeyedAccumulator[(Long, Long)]
+    cachedPlan.session.sparkContext.register(acc)
+    acc
+  }
 
   val cachedName = tableName.map(n => s"In-memory table $n")
     .getOrElse(Utils.abbreviate(cachedPlan.toString, 1024))
@@ -284,6 +296,11 @@ case class CachedRDDBuilder(
     if (_cachedColumnBuffers != null) {
       _cachedColumnBuffers.unpersist(blocking)
       _cachedColumnBuffers = null
+      // The buffers no longer back a live RDD. Reset the one-way "loaded" 
latch and the keyed
+      // bookkeeping so a rebuild on this builder does not inherit a stale 
"loaded" state or stale
+      // statistics. Safe to reset in place: every read of the accumulator is 
under this monitor.
+      _cachedColumnBuffersAreLoaded = false
+      partitionStats.reset()
     }
   }
 
@@ -296,9 +313,11 @@ case class CachedRDDBuilder(
       // We must make sure the statistics of `sizeInBytes` and `rowCount` are 
accurate if
       // `isCachedRDDLoaded` return true. Otherwise, AQE would do a wrong 
optimization,
       // e.g., convert a non-empty plan to empty local relation if `rowCount` 
is 0.
-      // Because the statistics is based on accumulator, here we use an extra 
accumulator to
-      // track if all partitions are materialized.
-      val rddLoaded = _cachedColumnBuffers.partitions.length == 
materializedPartitions.value
+      // Count DISTINCT materialized partitions (the keyed accumulator's key 
set), so the cache is
+      // only reported loaded once every partition has been computed -- sound 
even if a partition is
+      // computed more than once by concurrent or speculative tasks.
+      val numMaterialized = partitionStats.accumulatedNumPartitions
+      val rddLoaded = _cachedColumnBuffers.partitions.length.toLong == 
numMaterialized
       if (rddLoaded) {
         _cachedColumnBuffersAreLoaded = rddLoaded
       }
@@ -306,6 +325,21 @@ case class CachedRDDBuilder(
     }
   }
 
+  // Reported row count / size for the cache's statistics: exact and 
de-duplicated, folded over the
+  // distinct materialized partitions. Synchronized so a fold never races a 
concurrent `clearCache`
+  // reset.
+  private[sql] def materializedRowCount: Long = synchronized {
+    partitionStats.foldValues(0L)((sum, v) => sum + v._1)
+  }
+
+  private[sql] def materializedSizeInBytes: Long = synchronized {
+    partitionStats.foldValues(0L)((sum, v) => sum + v._2)
+  }
+
+  // The id of the accumulator backing this cache's materialization 
bookkeeping. Exposed only so
+  // `CachedTableSuite`'s accumulator-cleanup test can verify it is cleared 
after uncache + GC.
+  private[sql] def materializationAccumulatorId: Long = partitionStats.id
+
   private def buildBuffers(): RDD[CachedBatch] = {
     val cb = try {
       if (supportsColumnarInput) {
@@ -330,18 +364,29 @@ case class CachedRDDBuilder(
         session.sharedState.cacheManager.recacheByPlan(session, logicalPlan)
         throw e
     }
+    // Records one successful partition materialization: this partition's 
(rows, bytes) keyed by its
+    // id. Bound to a local so the task closure below captures only the 
accumulator, not the
+    // enclosing CachedRDDBuilder (whose cachedPlan is not serializable).
+    val accumulator = partitionStats
     val cached = cb.mapPartitionsInternal { it =>
-      TaskContext.get().addTaskCompletionListener[Unit] { context =>
+      val taskContext = TaskContext.get()
+      val partitionId = taskContext.partitionId()
+      // This task computes exactly one partition. Tally its totals so the 
completion listener
+      // records them once, keyed by partition id (covering empty-output 
partitions, which produce
+      // no batches).
+      var localRows = 0L
+      var localBytes = 0L
+      taskContext.addTaskCompletionListener[Unit] { context =>
         if (!context.isFailed() && !context.isInterrupted()) {
-          materializedPartitions.add(1L)
+          accumulator.add((partitionId, (localRows, localBytes)))
         }
       }
       new Iterator[CachedBatch] {
         override def hasNext: Boolean = it.hasNext
         override def next(): CachedBatch = {
           val batch = it.next()
-          sizeInBytesStats.add(batch.sizeInBytes)
-          rowCountStats.add(batch.numRows)
+          localBytes += batch.sizeInBytes
+          localRows += batch.numRows
           batch
         }
       }
@@ -460,8 +505,8 @@ case class InMemoryRelation(
       statsOfPlanToCache
     } else {
       statsOfPlanToCache.copy(
-        sizeInBytes = cacheBuilder.sizeInBytesStats.value.longValue,
-        rowCount = Some(cacheBuilder.rowCountStats.value.longValue)
+        sizeInBytes = cacheBuilder.materializedSizeInBytes,
+        rowCount = Some(cacheBuilder.materializedRowCount)
       )
     }
   }
diff --git 
a/sql/core/src/main/scala/org/apache/spark/sql/util/PartitionKeyedAccumulator.scala
 
b/sql/core/src/main/scala/org/apache/spark/sql/util/PartitionKeyedAccumulator.scala
new file mode 100644
index 000000000000..bb8f04a8a556
--- /dev/null
+++ 
b/sql/core/src/main/scala/org/apache/spark/sql/util/PartitionKeyedAccumulator.scala
@@ -0,0 +1,90 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *    http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.spark.sql.util
+
+import java.util.concurrent.ConcurrentHashMap
+
+import org.apache.spark.util.AccumulatorV2
+
+/**
+ * An `AccumulatorV2` that records one value of type `T` per partition, keyed 
by partition id with
+ * LAST-WRITE-WINS merge. When the same partition is recorded more than once 
-- e.g. duplicate
+ * cross-executor computes, or speculative tasks -- the later value replaces 
the earlier one rather
+ * than aggregating, so each partition contributes exactly once. The key set 
is the set of recorded
+ * partitions, and callers fold the values (see [[foldValues]]) to derive 
de-duplicated aggregates;
+ * a plain summing accumulator would instead over-count under duplicate 
computes.
+ *
+ * `add` is expected to be called once per task (e.g. from a task completion 
listener) with that
+ * partition's value, so a partition is recorded even when it produced 
nothing. Updates from
+ * failed/interrupted tasks are dropped by the accumulator framework (it is not
+ * `countFailedValues`), so only complete per-partition values are ever merged.
+ *
+ * Backed by a `ConcurrentHashMap`, whose per-entry atomicity is sufficient 
here: `add` and the
+ * `putAll` in `merge` are last-write-wins per key, and the reads (`value`,
+ * `accumulatedNumPartitions`, `foldValues`) only require thread-safety and 
eventual consistency
+ * -- they are weakly consistent during concurrent updates but exact once all 
updates have been
+ * merged. This avoids any explicit locking (and the nested-lock pattern a 
two-map `merge` would
+ * otherwise need).
+ *
+ * @tparam T the per-partition value type. Must be non-null 
(`ConcurrentHashMap` forbids nulls).
+ */
+class PartitionKeyedAccumulator[T] extends AccumulatorV2[(Int, T), 
java.util.Map[Int, T]] {
+
+  // partition id -> value.
+  private val byPartition = new ConcurrentHashMap[Int, T]()
+
+  override def isZero: Boolean = byPartition.isEmpty
+
+  override def copyAndReset(): PartitionKeyedAccumulator[T] = new 
PartitionKeyedAccumulator[T]
+
+  override def copy(): PartitionKeyedAccumulator[T] = {
+    val newAcc = new PartitionKeyedAccumulator[T]
+    newAcc.byPartition.putAll(byPartition)
+    newAcc
+  }
+
+  override def reset(): Unit = byPartition.clear()
+
+  override def add(v: (Int, T)): Unit = byPartition.put(v._1, v._2)
+
+  override def merge(other: AccumulatorV2[(Int, T), java.util.Map[Int, T]]): 
Unit = other match {
+    case o: PartitionKeyedAccumulator[T] =>
+      // Last-write-wins per partition id: a partition recorded by more than 
one task replaces
+      // rather than accumulates, keeping any caller-derived aggregate exact.
+      byPartition.putAll(o.byPartition)
+    case _ => throw new UnsupportedOperationException(
+      s"Cannot merge ${this.getClass.getName} with ${other.getClass.getName}")
+  }
+
+  // A read-only VIEW over the live map -- no copy. Only the accumulator 
framework calls `value`
+  // (event log / `toInfo` / `toString`); our own code reads via 
`accumulatedNumPartitions` /
+  // `foldValues`. The view is thread-safe (ConcurrentHashMap) and weakly 
consistent, which matches
+  // this accumulator's eventual-consistency contract.
+  override def value: java.util.Map[Int, T] = 
java.util.Collections.unmodifiableMap(byPartition)
+
+  /** Number of distinct partitions that have been recorded. */
+  def accumulatedNumPartitions: Long = byPartition.size().toLong
+
+  /** Folds the per-partition values (each partition counted once) into a 
single aggregate. */
+  def foldValues[A](zero: A)(op: (A, T) => A): A = {
+    var result = zero
+    val it = byPartition.values().iterator()
+    while (it.hasNext) result = op(result, it.next())
+    result
+  }
+}
diff --git 
a/sql/core/src/test/scala/org/apache/spark/sql/CachedTableSuite.scala 
b/sql/core/src/test/scala/org/apache/spark/sql/CachedTableSuite.scala
index 106ee36594b3..085dbcd80466 100644
--- a/sql/core/src/test/scala/org/apache/spark/sql/CachedTableSuite.scala
+++ b/sql/core/src/test/scala/org/apache/spark/sql/CachedTableSuite.scala
@@ -474,12 +474,12 @@ class CachedTableSuite extends SharedSparkSession
       val toBeCleanedAccIds = new HashSet[Long]
 
       val accId1 = spark.table("t1").queryExecution.withCachedData.collect {
-        case i: InMemoryRelation => i.cacheBuilder.sizeInBytesStats.id
+        case i: InMemoryRelation => i.cacheBuilder.materializationAccumulatorId
       }.head
       toBeCleanedAccIds += accId1
 
       val accId2 = spark.table("t1").queryExecution.withCachedData.collect {
-        case i: InMemoryRelation => i.cacheBuilder.sizeInBytesStats.id
+        case i: InMemoryRelation => i.cacheBuilder.materializationAccumulatorId
       }.head
       toBeCleanedAccIds += accId2
 
@@ -509,6 +509,37 @@ class CachedTableSuite extends SharedSparkSession
     }
   }
 
+  test("SPARK-57547: clearCache resets materialization bookkeeping") {
+    val df = spark.range(0, 100, 1, numPartitions = 4).filter($"id" >= 0)
+    df.cache()
+    try {
+      val cacheRelations = df.queryExecution.withCachedData.collect {
+        case i: InMemoryRelation => i
+      }
+      assert(cacheRelations.length == 1)
+      val builder = cacheRelations.head.cacheBuilder
+      // Force the cache build directly (a plain df action can be served from 
the query-result
+      // cache and skip the rebuild after clearCache).
+      builder.cachedColumnBuffers.count()
+      assert(builder.isCachedColumnBuffersLoaded)
+      assert(builder.materializedRowCount == 100L)
+
+      builder.clearCache()
+      // The loaded latch and the materialization stats must not survive 
clearCache, otherwise a
+      // rebuilt cache would inherit a stale "loaded" state with stale/zero 
statistics.
+      assert(!builder.isCachedColumnBuffersLoaded)
+      assert(builder.materializedRowCount == 0L)
+      assert(builder.materializedSizeInBytes == 0L)
+
+      // Rebuilding works and reports correct stats again.
+      builder.cachedColumnBuffers.count()
+      assert(builder.isCachedColumnBuffersLoaded)
+      assert(builder.materializedRowCount == 100L)
+    } finally {
+      df.unpersist(blocking = true)
+    }
+  }
+
   test("SPARK-10327 Cache Table is not working while subquery has alias in its 
project list") {
     withTempView("abc") {
       sparkContext.parallelize((1, 1) :: (2, 2) :: Nil)
diff --git 
a/sql/core/src/test/scala/org/apache/spark/sql/execution/columnar/ConcurrentInMemoryRelationSuite.scala
 
b/sql/core/src/test/scala/org/apache/spark/sql/execution/columnar/ConcurrentInMemoryRelationSuite.scala
new file mode 100644
index 000000000000..161f8ec647a2
--- /dev/null
+++ 
b/sql/core/src/test/scala/org/apache/spark/sql/execution/columnar/ConcurrentInMemoryRelationSuite.scala
@@ -0,0 +1,325 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *    http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.spark.sql.execution.columnar
+
+import java.io.File
+import java.util.concurrent.CountDownLatch
+
+import scala.concurrent.duration._
+
+import org.scalatest.concurrent.Eventually
+import org.scalatest.time.{Millis, Seconds, Span}
+
+import org.apache.spark.{LocalSparkContext, SparkConf, SparkContext, 
SparkFunSuite}
+import org.apache.spark.sql.{DataFrame, Dataset, SparkSession}
+import org.apache.spark.sql.columnar.CachedBatch
+import org.apache.spark.sql.functions.{lit, when}
+import org.apache.spark.util.{ThreadUtils, Utils}
+
+/**
+ * Regression test for SPARK-57547: concurrent first-touch of one cold table 
cache must not let
+ * duplicate partition computes silently drop rows.
+ *
+ * AQE creates a separate `TableCacheQueryStageExec` for every reference to 
the same cache (table
+ * cache stages are never reused), and each one submits its own build job over 
the shared cache RDD.
+ * A query that references a cached relation several times therefore 
first-touches the cold cache
+ * from several jobs at once. Spark has no global cross-executor "compute this 
partition once"
+ * barrier, so the same partition can be computed by multiple executors. If 
the cache decided it was
+ * "loaded" from a raw task-completion count (the legacy behavior), those 
duplicate completions
+ * could push the count to the partition count while a row-producing partition 
was still being
+ * computed, falsely marking the cache loaded with rowCount 0 -- which lets 
AQE propagate an empty
+ * relation and silently lose rows.
+ *
+ * The fix counts the DISTINCT set of materialized partitions instead, so 
duplicate computes can
+ * no longer mark the cache loaded early. These tests reproduce the race 
deterministically: a
+ * two-stage gate holds the row-producing partition while the empty-output 
partition's duplicate
+ * cross-executor completions accumulate. With distinct tracking the cache 
stays correctly
+ * not-loaded while a partition is still building, so the consumer observes 
every row; were the
+ * loaded check to fall back to a raw task-completion count it would latch the 
cache as loaded
+ * with rowCount 0 and let AQE propagate an empty relation, losing rows (which 
the repro detects
+ * as a row-count mismatch). A multi-executor `local-cluster` session is 
required so the duplicate
+ * computes land on different executors.
+ */
+class ConcurrentInMemoryRelationSuite extends SparkFunSuite with 
LocalSparkContext with Eventually {
+
+  private def cacheBuilderOf(ds: Dataset[_]): CachedRDDBuilder = {
+    val relations = ds.queryExecution.withCachedData.collect { case i: 
InMemoryRelation => i }
+    assert(relations.length == 1)
+    relations.head.cacheBuilder
+  }
+
+  private def withSession(numExecutors: Int = 4)(
+      f: SparkSession => Unit): Unit = {
+    val conf = new SparkConf()
+      .setMaster(s"local-cluster[$numExecutors,1,1024]")
+      .setAppName("ConcurrentInMemoryRelationSuite")
+    sc = new SparkContext(conf)
+    try {
+      // Wait for all executors to register so tasks spread one-per-executor 
as the tests assume.
+      eventually(timeout(Span(60, Seconds)), interval(Span(200, Millis))) {
+        assert(sc.getExecutorIds().size == numExecutors)
+      }
+      f(SparkSession.builder().sparkContext(sc).getOrCreate())
+    } finally {
+      resetSparkContext()
+    }
+  }
+
+  /**
+   * Drives the actual SPARK-57547 data loss deterministically.
+   *
+   * Caches a skewed join with two shuffle partitions: every partition has 
non-empty INPUT (so
+   * neither is pruned as an empty task), but only the `skewKey` bucket 
produces OUTPUT rows -- so
+   * one partition is row-producing and the other produces zero rows. A 
two-stage gate blocks every
+   * partition's build inside `mapPartitions` until released. `numReferences` 
threads each submit
+   * their own build job over the shared cache RDD (exactly as per-reference
+   * `TableCacheQueryStageExec`s do); on `local-cluster[4,1,...]` (= 
numReferences x cachePartitions
+   * task slots, one task per executor) the empty-output partition is computed 
by both references on
+   * two distinct executors.
+   *
+   * Sequence: (1) the threads first-touch the cold cache, gating all 
`numReferences x
+   * cachePartitions` tasks; (2) release only the empty-output partition, so 
its two
+   * cross-executor completions land while the row-producing partition is 
still gated; (3) poll
+   * `isCachedColumnBuffersLoaded` -- distinct-partition tracking keeps it 
false (a raw
+   * task-completion count would instead reach cachePartitions here and latch 
a poisoned
+   * "loaded" state with rowCount 0); (4) a consumer query (a GROUPED 
aggregate, where empty
+   * propagation could collapse the result) sees the cache not-loaded and 
plans against the
+   * real rows -- had it been poisoned, AQE would have propagated an empty 
relation and dropped
+   * rows; (5) release the producing partition. Returns (rows the consumer 
observed, expected
+   * rows), equal unless poisoned.
+   */
+  private def runDataLossRepro(spark: SparkSession): (Long, Long) = {
+    import spark.implicits._
+    val numKeys = 64
+    val skewKey = 42
+    val rowsPerKey = 500
+    val numReferences = 2
+    val cachePartitions = 2 // one row-producing, one empty-output (see 
shuffle.partitions below)
+    val expected = rowsPerKey.toLong * rowsPerKey.toLong // only the skewKey 
bucket joins
+
+    // Exactly two shuffle partitions (one row-producing, one empty-output), 
no broadcast so the
+    // join shuffles, and build the cache with AQE off so the skewed producing 
partition is not
+    // rebalanced away (which would defuse the window). Consumers below run 
with AQE on so they go
+    // through TableCacheQueryStageExec + empty-relation propagation.
+    spark.conf.set("spark.sql.shuffle.partitions", "2")
+    spark.conf.set("spark.sql.autoBroadcastJoinThreshold", "-1")
+    spark.conf.set("spark.sql.adaptive.coalescePartitions.enabled", "false")
+    spark.conf.set("spark.sql.adaptive.skewJoin.enabled", "false")
+    spark.conf.set("spark.sql.adaptive.enabled", "false")
+
+    val gateDir = Utils.createTempDir()
+    def file(name: String) = new File(gateDir, name)
+    val releaseEmpty = file("releaseEmpty").getAbsolutePath
+    val releaseProducing = file("releaseProducing").getAbsolutePath
+    val entryDir = gateDir.getAbsolutePath
+
+    def side(matchSalt: Int, valueCol: String): DataFrame =
+      spark.range(0, numKeys.toLong * rowsPerKey).select(
+        ($"id" % numKeys).cast("int").as("k"),
+        when(($"id" % numKeys) === skewKey, 
lit(0)).otherwise(lit(matchSalt)).as("salt"),
+        $"id".as(valueCol))
+
+    val joined = side(1, "lv")
+      .join(side(2, "rv"), Seq("k", "salt"))
+      .select($"k", $"lv").as[(Int, Long)]
+
+    // Two-stage gate: every partition signals it has entered (past the 
block-existence check) and
+    // waits for releaseEmpty; the row-producing partition (the only one with 
rows) waits longer.
+    val gated = joined.mapPartitions { iter =>
+      val buffered = iter.buffered
+      val isProducing = buffered.hasNext
+      file(s"entered-${java.util.UUID.randomUUID()}").createNewFile()
+      def waitFor(path: String): Unit = {
+        val deadline = System.currentTimeMillis() + 60000
+        while (!new File(path).exists() && System.currentTimeMillis() < 
deadline) Thread.sleep(50)
+      }
+      waitFor(releaseEmpty)
+      if (isProducing) waitFor(releaseProducing)
+      buffered
+    }.toDF("k", "lv")
+
+    val cached = gated.cache()
+    try {
+      val builder = cacheBuilderOf(cached)
+      // Cache plan captured (static, 2 partitions); consumers from here on 
use AQE.
+      spark.conf.set("spark.sql.adaptive.enabled", "true")
+      // Every reference launches its own build job over the shared cache RDD 
(no dedup at this
+      // layer), so the empty partition is computed by every reference: 
numReferences x
+      // cachePartitions gated tasks.
+      val expectedEntries = numReferences * cachePartitions
+      val rdd = builder.cachedColumnBuffers
+      val submitted = new CountDownLatch(numReferences)
+      val pool = ThreadUtils.newDaemonFixedThreadPool(numReferences, 
"spark57547-dataloss")
+      try {
+        val firstTouch = (1 to numReferences).map { _ =>
+          pool.submit(new java.util.concurrent.Callable[Unit] {
+            override def call(): Unit = {
+              val f = spark.sparkContext.submitJob(
+                rdd,
+                (_: Iterator[CachedBatch]) => (),
+                0 until rdd.getNumPartitions,
+                (_: Int, _: Unit) => (),
+                ())
+              submitted.countDown()
+              ThreadUtils.awaitResult(f, 120.seconds)
+            }
+          })
+        }
+        assert(submitted.await(60, java.util.concurrent.TimeUnit.SECONDS))
+        // Wait until every build task is parked at the gate (all have passed 
the block-existence
+        // check), so releasing the empty partition forces its cross-executor 
completions to run.
+        eventually(timeout(Span(60, Seconds)), interval(Span(100, Millis))) {
+          val entered = new 
File(entryDir).listFiles().count(_.getName.startsWith("entered-"))
+          assert(entered == expectedEntries, s"entered=$entered 
expected=$expectedEntries")
+        }
+
+        // Stage 1: release ONLY the empty-output partition; the producing 
partition stays gated.
+        assert(new File(releaseEmpty).createNewFile())
+
+        // Were the loaded check to fall back to a raw task-completion count, 
the empty partition's
+        // duplicate cross-executor completions would push that count to 
cachePartitions even though
+        // the producing partition has not run, latching the cache as "loaded" 
with rowCount 0. We
+        // read it through the relation handle -- exactly what AQE's stats 
reads do in production --
+        // and the one-way latch would make the poison permanent (the 
producing partition is still
+        // gated when the consumer runs below). With distinct-partition 
accounting the cache stays
+        // not loaded here, so this poll times out and we fall through to a 
normal (complete) build.
+        val poisoned =
+          try {
+            eventually(timeout(Span(30, Seconds)), interval(Span(100, 
Millis))) {
+              assert(builder.isCachedColumnBuffersLoaded)
+            }
+            true
+          } catch {
+            case _: org.scalatest.exceptions.TestFailedException => false
+          }
+
+        // A GROUPED aggregate (not a global count): AQE empty-relation 
propagation collapses the
+        // whole result when the cache stage is (falsely) reported as a 
zero-row materialized stage;
+        // a global aggregate over empty would still emit one row and mask the 
loss.
+        val observed = if (poisoned) {
+          // The cache lied (loaded with rowCount 0 while the producing 
partition is still gated and
+          // unbuilt). The consumer plans against it and AQE propagates an 
empty relation, so the
+          // rows silently vanish. The producing partition stays gated, so 
this is deterministic.
+          val consumer = cached.groupBy("k").count()
+          val rows = consumer.collect()
+          
assert(consumer.queryExecution.executedPlan.toString.contains("EmptyRelation"),
+            "expected AQE to propagate an empty relation from the poisoned 
cache stage")
+          assert(new File(releaseProducing).createNewFile()) // unblock the 
build for clean shutdown
+          rows.map(_.getLong(1)).sum
+        } else {
+          // The cache is correctly not loaded, so let the producing partition 
finish and the
+          // consumer observes every row.
+          assert(new File(releaseProducing).createNewFile())
+          cached.groupBy("k").count().collect().map(_.getLong(1)).sum
+        }
+        firstTouch.foreach(_.get(120, java.util.concurrent.TimeUnit.SECONDS))
+        (observed, expected)
+      } finally {
+        pool.shutdown()
+      }
+    } finally {
+      cached.unpersist(blocking = true)
+      Utils.deleteRecursively(gateDir)
+    }
+  }
+
+  /**
+   * Builds a cold cache whose partitions all carry rows and first-touches it 
concurrently from
+   * `numReferences` jobs with every partition gated, so each partition is 
computed once per
+   * reference on a distinct executor (`numReferences` duplicate 
cross-executor computes per
+   * partition). Returns (reported materialized row count, expected rows); 
with distinct-partition
+   * tracking on, the keyed accumulator de-duplicates the duplicate computes 
so the count is exact.
+   */
+  private def runDuplicateComputeStats(spark: SparkSession): (Long, Long) = {
+    import spark.implicits._
+    val numReferences = 2
+    val cachePartitions = 2
+    val numRows = 200L // split evenly across the partitions; every partition 
is non-empty
+
+    val gateDir = Utils.createTempDir()
+    def file(name: String) = new File(gateDir, name)
+    val release = file("release").getAbsolutePath
+    val entryDir = gateDir.getAbsolutePath
+
+    // Every partition has rows and blocks at the gate until released, so all 
references' build
+    // tasks are in flight (past the block-existence check) before any 
completes -- forcing the
+    // duplicate cross-executor computes that the per-batch accumulator would 
over-count.
+    val cached = spark.range(0, numRows, 1, 
cachePartitions).as[Long].mapPartitions { iter =>
+      file(s"entered-${java.util.UUID.randomUUID()}").createNewFile()
+      val deadline = System.currentTimeMillis() + 60000
+      while (!new File(release).exists() && System.currentTimeMillis() < 
deadline) Thread.sleep(50)
+      iter
+    }.cache()
+    try {
+      val builder = cacheBuilderOf(cached)
+      val rdd = builder.cachedColumnBuffers
+      val submitted = new CountDownLatch(numReferences)
+      val pool = ThreadUtils.newDaemonFixedThreadPool(numReferences, 
"spark57547-stats")
+      try {
+        val futures = (1 to numReferences).map { _ =>
+          pool.submit(new java.util.concurrent.Callable[Unit] {
+            override def call(): Unit = {
+              val f = spark.sparkContext.submitJob(
+                rdd,
+                (_: Iterator[CachedBatch]) => (),
+                0 until rdd.getNumPartitions,
+                (_: Int, _: Unit) => (),
+                ())
+              submitted.countDown()
+              ThreadUtils.awaitResult(f, 120.seconds)
+            }
+          })
+        }
+        assert(submitted.await(60, java.util.concurrent.TimeUnit.SECONDS))
+        // Wait until every reference's task for every partition is parked at 
the gate, then release
+        // them so each partition is computed once per reference.
+        eventually(timeout(Span(60, Seconds)), interval(Span(100, Millis))) {
+          val entered = new 
File(entryDir).listFiles().count(_.getName.startsWith("entered-"))
+          assert(entered == numReferences * cachePartitions, 
s"entered=$entered")
+        }
+        assert(new File(release).createNewFile())
+        futures.foreach(_.get(120, java.util.concurrent.TimeUnit.SECONDS))
+        assert(builder.isCachedColumnBuffersLoaded)
+        (builder.materializedRowCount, numRows)
+      } finally {
+        pool.shutdown()
+      }
+    } finally {
+      cached.unpersist(blocking = true)
+      Utils.deleteRecursively(gateDir)
+    }
+  }
+
+  test("SPARK-57547: concurrent first-touch of a cold cache does not lose 
rows") {
+    withSession() { spark =>
+      val (observed, expected) = runDataLossRepro(spark)
+      assert(observed == expected, s"consumer observed $observed rows, 
expected $expected")
+    }
+  }
+
+  test("SPARK-57547: cache statistics are exact under duplicate cross-executor 
computes") {
+    // Every partition is computed by both references, so the partition-keyed 
accumulator sees a
+    // duplicate `add` per partition. Last-write-wins de-duplication keeps the 
reported row count
+    // exact -- a naive summing accumulator would over-count under these 
duplicate computes.
+    withSession() { spark =>
+      val (rowCount, expected) = runDuplicateComputeStats(spark)
+      assert(rowCount == expected,
+        s"partition-keyed accumulator should report exact row count $expected, 
got $rowCount")
+    }
+  }
+}
diff --git 
a/sql/core/src/test/scala/org/apache/spark/sql/execution/columnar/InMemoryColumnarQuerySuite.scala
 
b/sql/core/src/test/scala/org/apache/spark/sql/execution/columnar/InMemoryColumnarQuerySuite.scala
index 5cd62302861a..57da12e87979 100644
--- 
a/sql/core/src/test/scala/org/apache/spark/sql/execution/columnar/InMemoryColumnarQuerySuite.scala
+++ 
b/sql/core/src/test/scala/org/apache/spark/sql/execution/columnar/InMemoryColumnarQuerySuite.scala
@@ -361,7 +361,7 @@ class InMemoryColumnarQuerySuite extends SharedSparkSession 
with AdaptiveSparkPl
     checkAnswer(cached, expectedAnswer)
 
     // Check that the right size was calculated.
-    assert(cached.cacheBuilder.sizeInBytesStats.value === 
expectedAnswer.length * INT.defaultSize)
+    assert(cached.cacheBuilder.materializedSizeInBytes === 
expectedAnswer.length * INT.defaultSize)
   }
 
    test("cached row count should be calculated") {
@@ -375,7 +375,7 @@ class InMemoryColumnarQuerySuite extends SharedSparkSession 
with AdaptiveSparkPl
     checkAnswer(cached, expectedAnswer)
 
     // Check that the right row count was calculated.
-    assert(cached.cacheBuilder.rowCountStats.value === 6)
+    assert(cached.cacheBuilder.materializedRowCount === 6)
   }
 
   test("access primitive-type columns in CachedBatch without whole stage 
codegen") {
diff --git 
a/sql/core/src/test/scala/org/apache/spark/sql/util/PartitionKeyedAccumulatorSuite.scala
 
b/sql/core/src/test/scala/org/apache/spark/sql/util/PartitionKeyedAccumulatorSuite.scala
new file mode 100644
index 000000000000..19e499942e31
--- /dev/null
+++ 
b/sql/core/src/test/scala/org/apache/spark/sql/util/PartitionKeyedAccumulatorSuite.scala
@@ -0,0 +1,107 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *    http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.spark.sql.util
+
+import org.apache.spark.SparkFunSuite
+
+class PartitionKeyedAccumulatorSuite extends SparkFunSuite {
+
+  // The cache use case records (rowCount, sizeInBytes) per partition.
+  private type Stats = (Long, Long)
+
+  private def sumRows(acc: PartitionKeyedAccumulator[Stats]): Long =
+    acc.foldValues(0L)((sum, v) => sum + v._1)
+
+  private def sumBytes(acc: PartitionKeyedAccumulator[Stats]): Long =
+    acc.foldValues(0L)((sum, v) => sum + v._2)
+
+  test("isZero, add, value and accumulatedNumPartitions") {
+    val acc = new PartitionKeyedAccumulator[Stats]
+    assert(acc.isZero)
+    assert(acc.accumulatedNumPartitions == 0)
+    assert(acc.value.isEmpty)
+
+    acc.add((0, (10L, 100L)))
+    assert(!acc.isZero)
+    assert(acc.accumulatedNumPartitions == 1)
+    assert(acc.value.get(0) == ((10L, 100L)))
+
+    acc.add((1, (5L, 50L)))
+    assert(acc.accumulatedNumPartitions == 2)
+    assert(sumRows(acc) == 15L)
+    assert(sumBytes(acc) == 150L)
+  }
+
+  test("add is last-write-wins for the same partition id") {
+    val acc = new PartitionKeyedAccumulator[Stats]
+    acc.add((0, (1L, 1L)))
+    acc.add((0, (2L, 2L))) // re-records partition 0 (e.g. a recompute)
+    assert(acc.accumulatedNumPartitions == 1)
+    assert(sumRows(acc) == 2L) // the later value wins, not 1 + 2
+    assert(sumBytes(acc) == 2L)
+  }
+
+  test("merge is last-write-wins per partition id (de-duplicates, does not 
sum)") {
+    // Two references compute the same partitions; partition 0 is computed by 
both.
+    val a = new PartitionKeyedAccumulator[Stats]
+    a.add((0, (10L, 100L)))
+
+    val b = new PartitionKeyedAccumulator[Stats]
+    b.add((0, (10L, 100L))) // duplicate compute of partition 0
+    b.add((1, (5L, 50L)))
+
+    a.merge(b)
+    assert(a.accumulatedNumPartitions == 2) // partitions {0, 1}, not 3
+    assert(sumRows(a) == 15L) // 10 (partition 0, counted once) + 5, NOT 25
+    assert(sumBytes(a) == 150L)
+  }
+
+  test("copy is an independent snapshot") {
+    val acc = new PartitionKeyedAccumulator[Stats]
+    acc.add((0, (10L, 100L)))
+    val snapshot = acc.copy()
+    acc.add((1, (5L, 50L))) // mutate the original after copying
+
+    assert(snapshot.accumulatedNumPartitions == 1)
+    assert(sumRows(snapshot) == 10L)
+    assert(acc.accumulatedNumPartitions == 2)
+    assert(sumRows(acc) == 15L)
+  }
+
+  test("reset and copyAndReset") {
+    val acc = new PartitionKeyedAccumulator[Stats]
+    acc.add((0, (10L, 100L)))
+    assert(!acc.isZero)
+
+    assert(acc.copyAndReset().isZero)
+    assert(!acc.isZero) // copyAndReset does not mutate the source
+
+    acc.reset()
+    assert(acc.isZero)
+    assert(acc.accumulatedNumPartitions == 0)
+  }
+
+  test("works for an arbitrary value type") {
+    val acc = new PartitionKeyedAccumulator[String]
+    acc.add((0, "a"))
+    acc.add((1, "b"))
+    acc.add((0, "c")) // last-write-wins
+    assert(acc.accumulatedNumPartitions == 2)
+    assert(acc.foldValues("")((s, v) => s + v).length == 2) // "c" + "b" (each 
partition once)
+  }
+}


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


Reply via email to