dongjoon-hyun commented on code in PR #58097:
URL: https://github.com/apache/spark/pull/58097#discussion_r3972727861


##########
core/src/main/scala/org/apache/spark/scheduler/DAGScheduler.scala:
##########
@@ -2604,6 +2919,67 @@ private[spark] class DAGScheduler(
       .getOrElse(new Properties())
     addPySparkConfigsToProperties(stage, properties)
 
+    // For a pipelined PRODUCER stage, tell its tasks which of its reduce 
partitions the job
+    // actually reads. The in-process channel writer drops records routed to 
partitions no
+    // consumer will drain -- otherwise a partial-read job (LIMIT / 
executeTake reads a subset)
+    // fills the unread partitions' bounded queues and deadlocks the writer. 
The result stage
+    // is created before submitStage, so its partitions are known here.
+    //
+    // The live set is per-SHUFFLE-EDGE, not per-job: it is the reduce 
partitions the consumer of
+    // THIS shuffle reads. liveReduceSet computes it by walking the narrow 
chain from the result
+    // RDD down to this shuffle, threading the read partition subset through 
each dependency's
+    // getParents. A MIDDLE pipelined exchange in a chain (e.g. a subquery's 
hash below a
+    // single-partition agg) is consumed by another map stage that reads ALL 
its partitions, and
+    // its shuffle is not narrow-reachable from the result RDD (an intervening 
shuffle blocks the
+    // walk), so liveReduceSet returns None and the property is left unset -- 
fully live -- which
+    // is correct. See the None handling below for the fail-fast case.
+    stage match {
+      case sms: ShuffleMapStage
+          if isPipelinedProducer(stage) && 
pipelinedManagerWantsLiveReduceHints =>
+        val resultStage = jobIdToActiveJob.get(jobId).map(_.finalStage)
+          .collect { case rs: ResultStage => rs }
+        resultStage.foreach { rs =>
+          // Tell this pipelined producer's tasks which of ITS reduce 
partitions the job's readers
+          // will actually drain, so the writer can drop records routed to 
partitions no consumer
+          // reads (a partial read -- LIMIT / executeTake -- runs only a 
subset of the result
+          // stage's partitions; feeding the rest fills their bounded queues 
and deadlocks the
+          // writer). This is the reduce-partition set the result stage's 
partition subset maps to
+          // through the narrow chain down to this shuffle (see liveReduceSet).
+          liveReduceSet(rs.rdd, rs.partitions.toSet, sms.shuffleDep.shuffleId) 
match {

Review Comment:
   **Hang: the live set ignores cached partitions.**
   
   `liveReduceSet` is derived from the partitions the result stage will *run*, 
but a result task whose RDD block is cached returns from `RDD.getOrCompute` 
without ever constructing a `ChannelShuffleReader`, so its queue is never 
`abandon`ed.
   
   ```scala
   val df = spark.range(0, 4000000, 1, 4).repartition(8)
   df.persist(MEMORY_ONLY); df.collect()
   // memory pressure evicts partitions 0-5 only
   df.collect()
   ```
   `getMissingParentStages` sees the cache misses and resubmits the producer as 
a fresh stage (empty `pipelinedCompletedPartitions`) with all 8 partitions 
live; tasks 6 and 7 are served from the block store and never open a reader; 
once partition 6/7's queue reaches capacity every map task parks in the `offer` 
loop forever and no consumer receives `EndOfStream`.
   
   None of `classifyJobShuffleShape`, `pipelinedJobConcurrentTaskDemand`, 
`liveReduceSet` or `rddReachesShuffle` consult `getCacheLocs`, neither SQL rule 
has a persist/cache guard, and no test covers persistence. A fully cached plan 
does not hang but is still counted as phantom admission demand. 
`InMemoryRelation` caches `executedPlan`, so a `persist()` above a flipped 
exchange is a normal SQL shape, not an RDD corner case.



##########
sql/core/src/main/scala/org/apache/spark/sql/execution/exchange/EnablePipelinedShuffle.scala:
##########
@@ -0,0 +1,148 @@
+/*
+ * 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.exchange
+
+import org.apache.spark.sql.catalyst.rules.Rule
+import org.apache.spark.sql.execution.{CoalesceExec, CollectLimitExec, 
CollectTailExec, SparkPlan, TakeOrderedAndProjectExec}
+import org.apache.spark.sql.execution.joins.CartesianProductExec
+
+/**
+ * Opt-in (SPARK-57399). Rewrites EVERY [[ShuffleExchangeExec]] in a
+ * batch physical plan to `pipelined = true`, so each shuffle is served by the 
in-process
+ * pipelined channel manager and the concurrent-stage scheduler runs the map 
and reduce stages
+ * together.
+ *
+ * The rewrite is deliberately unconditional rather than cost-based: the 
scheduler requires a job to
+ * be all-pipelined (or a materialized prefix below a pipelined suffix), so 
choosing per exchange
+ * would produce exactly the mixed shapes it rejects. What the rule does 
decide is ELIGIBILITY --
+ * the environment gates in [[PipelinedShuffleEligibility]] and the plan 
shapes below that force the
+ * whole plan back to regular.
+ *
+ * Enabled only when `spark.sql.shuffle.localPipelined.enabled=true`. It runs 
in the non-AQE
+ * `preparations` list, so it also requires AQE to be off (under AQE the plan 
is hidden behind
+ * an opaque `AdaptiveSparkPlanExec` leaf and this rule sees no exchanges).
+ *
+ * Rewriting ALL shuffles (not just hash-partitioning ones) keeps the job 
all-pipelined, which
+ * the DAGScheduler requires: a mix of pipelined and regular shuffles in one 
job is rejected.
+ * SinglePartition and RangePartitioning exchanges pipeline fine -- the 
channel transport only
+ * routes by `partitioner.getPartition(key)` and does not care which 
partitioning produced the
+ * id (SinglePartition is the numPartitions == 1 degenerate case).
+ *
+ * These shapes make the rule leave the whole plan regular:
+ *   - reuse: a pipelined producer with more than one consumer (fan-out) is 
rejected, so if any
+ *     exchange in the plan is reused the rule bails out.
+ *   - an UNSUPPORTED CONSUMER reading a shuffle (see 
[[readsShuffleThroughUnsupportedConsumer]]):
+ *     an operator that would drain a shuffle in a way the channel transport 
cannot serve, or that
+ *     builds its own hidden regular shuffle. If such an operator sits above 
any shuffle the rule
+ *     leaves the WHOLE plan regular (leaving only that one exchange regular 
would put a pipelined
+ *     exchange below a regular boundary, which the scheduler rejects -- so it 
is all-or-nothing).
+ *     The query runs correctly, just not pipelined. The unsupported consumers 
are:
+ *       - `CoalesceExec` (user `.coalesce(n)`): its `CoalescedRDD` makes ONE 
reduce task drain
+ *         SEVERAL reduce partitions sequentially. The single-threaded writer 
parks on a full
+ *         bounded queue filling a later partition before emitting an earlier 
one's markers, so a
+ *         reader draining partitions in order deadlocks the writer with no 
timeout escape;
+ *         `coalesce`'s narrow-merge contract also cannot be honored by 
re-hashing to `n`.
+ *       - `CartesianProductExec`: its `UnsafeCartesianRDD` reads each left 
(child) partition once
+ *         per right partition, so N reduce tasks mint N readers on the SAME 
rendezvous queue for
+ *         one `(shuffleId, epoch, pid)` -- rows and end-of-stream markers 
split
+ *         nondeterministically (wrong results), a reader short of `numMaps` 
markers hangs, and
+ *         the first to finish abandons the queue and discards the others' 
data. The fan-out check
+ *         does not catch it (one consumer RDD, computed many times), nor the 
width-1 require.
+ *       - `CollectLimitExec` / `CollectTailExec` / 
`TakeOrderedAndProjectExec`: each builds a
+ *         hidden regular (`pipelined = false`) shuffle inside `doExecute` via
+ *         `prepareShuffleDependency`, invisible to this plan walk. A flipped 
exchange below one of
+ *         them would sit under that unmaterialized regular boundary and the 
job would hard-fail at
+ *         submission (`classifyJobShuffleShape`'s pipelined-below-regular 
rejection). (`.collect()`
+ *         on a limit takes `executeTake` and never hits `doExecute`; `.write` 
/ `.toLocalIterator`
+ *         / a non-root position do.)
+ */
+object EnablePipelinedShuffle extends Rule[SparkPlan] {
+
+  override def apply(plan: SparkPlan): SparkPlan = {
+    // Shared environment gate (opt-in flag, single-executor local mode, 
channel manager active),
+    // identical to the AQE rule's -- see PipelinedShuffleEligibility for why 
it is a correctness
+    // gate. It also requires AQE off implicitly: under AQE this rule sees no 
exchanges.
+    if (!PipelinedShuffleEligibility.enabled(plan, conf)) return plan
+
+    val shuffles = plan.collect { case s: ShuffleExchangeExec => s }
+    if (shuffles.isEmpty) return plan
+
+    // A reused exchange has more than one consumer; a pipelined producer 
cannot fan out, so
+    // leave the whole plan regular rather than produce a rejected job. Check 
subquery plans
+    // too (plan.exists walks the operator tree only): today no SQL shape can 
place a reused
+    // PIPELINED exchange there -- same-tree reuse is caught here, 
main-vs-subquery reuse
+    // never fires because the subquery's own preparation pass (PlanSubqueries 
->
+    // prepareExecutedPlan, which includes this rule) flips its exchanges 
pipelined BEFORE
+    // the outer ReuseExchangeAndSubquery compares canonical forms, and 
subquery-vs-subquery
+    // duplication is collapsed by MergeScalarSubqueries / subquery reuse 
first -- but the
+    // second mechanism is an accident of rule ordering and the third is 
optimizer behavior,
+    // so this gate does not rely on either.
+    if (plan.collectWithSubqueries { case r: ReusedExchangeExec => r 
}.nonEmpty) {
+      // Not a warning: this is a normal, expected fallback (reuse is routine 
optimizer output,
+      // e.g. self-joins), the query still runs correctly as a regular 
shuffle, and the user has
+      // nothing to act on. Log at DEBUG as diagnostic ("why this query did 
not go pipelined")
+      // rather than WARN, which would fire on every reuse-bearing query and 
read as a fault.
+      logDebug("EnablePipelinedShuffle: plan has a reused exchange; leaving it 
regular to " +
+        "avoid a fan-out pipelined job.")
+      return plan
+    }
+
+    // An operator that would read a shuffle in a way the channel transport 
cannot serve, or that
+    // builds its own hidden regular shuffle, forces the whole plan regular 
(see class doc). Like
+    // the reuse fallback this is a normal, expected outcome, so log at DEBUG 
rather than WARN.
+    if (readsShuffleThroughUnsupportedConsumer(plan)) {
+      logDebug("EnablePipelinedShuffle: a shuffle is read through an operator 
the channel " +
+        "transport cannot serve (coalesce / cartesian product / a limit 
operator that builds a " +
+        "hidden shuffle); leaving the plan regular.")
+      return plan
+    }
+
+    plan.transformUp {
+      case s: ShuffleExchangeExec if !s.pipelined => s.copy(pipelined = true)
+    }
+  }
+
+  /**
+   * True if any [[ShuffleExchangeExec]] in `plan` is read by an operator the 
channel transport
+   * cannot serve. The unsupported operators (see class doc for why each is 
fatal) are
+   * `CoalesceExec`, `CartesianProductExec`, and the limit operators 
`CollectLimitExec` /
+   * `CollectTailExec` / `TakeOrderedAndProjectExec`. For each such operator 
anywhere in the plan,
+   * check whether a shuffle is reachable below it.
+   *
+   * The reachability walk descends through EVERY child of a non-exchange node 
-- not only unary
+   * children -- so a shuffle behind a `UnionExec`/join (a `BinaryExecNode`) 
beneath the operator
+   * is still found. It stops at the FIRST [[ShuffleExchangeExec]] on each 
path: a shuffle deeper
+   * than that first one is not read by this operator (the intervening 
exchange's own reader reads
+   * one reduce partition per task), so it is not this operator's concern.
+   */
+  private def readsShuffleThroughUnsupportedConsumer(plan: SparkPlan): Boolean 
= {

Review Comment:
   **Deadlock: RDD-API narrow consumers over `Dataset.rdd` bypass this guard.**
   
   This only inspects `SparkPlan` nodes, but `Dataset.rdd` / `toRdd` hands the 
pipelined `ShuffledRowRDD` to user code. An RDD-level `coalesce` (narrow 
`CoalescedRDD`) drains several reduce partitions per task, and `r.union(r)` / 
`r.zip(r)` open two readers on the same `(shuffleId, epoch, pid)` queue. 
Nothing in `DAGScheduler` or the transport rejects either:
   
   - `CoalescedRDD.compute` opens width-1 readers sequentially, so the reader's 
`require(endPartition - startPartition == 1)` passes.
   - `liveReduceSet` maps `{0,1} -> {0..7}` via `NarrowDependency.getParents`, 
and the fan-out check counts consumer RDD ids (one `ShuffledRowRDD`), so both 
pass.
   
   Repro (flag on, channel manager, `local[16]`):
   ```scala
   spark.range(0, 2000000, 1, 4).repartition(8).rdd.coalesce(2).count()
   ```
   Task 0 drains partition 0 waiting for `numMaps` end-of-stream markers, while 
every map task parks forever in `putUnlessAbandoned` on partition 1's full 
queue (64 x 1024 rows) before it can emit partition 0's marker. Permanent hang, 
no kill to escape. For `r.union(r)`, the `numMaps` markers are split between 
two readers so at least one never completes (or one abandons and clears the 
other's data).
   
   The PR's own `Dataset.rdd` test only applies a narrow 
`mapPartitionsWithIndex`. Either bail out of the rule when the plan root is 
`DeserializeToObjectExec` (the `.rdd` boundary), or enforce the "exactly one 
reader per (shuffle, epoch, partition)" invariant in the rendezvous itself 
(fail loud on a second `getReader` / on fan-in > 1 in `liveReduceSet`), which 
would also cover the RDD path.



##########
core/src/main/scala/org/apache/spark/scheduler/DAGScheduler.scala:
##########
@@ -1129,6 +1189,93 @@ private[spark] class DAGScheduler(
     (hasPipelined, hasRegular)
   }
 
+  /** Classify `finalRDD`'s shuffle graph; see [[JobShuffleShape]] for the 
shape semantics. */
+  private[scheduler] def classifyJobShuffleShape(finalRDD: RDD[_]): 
JobShuffleShape = {
+    // Cheap pre-pass first: which KINDS of boundary the graph has, over the 
shared
+    // `traverseRDDGraph` (a HashSet[RDD] visited set, no per-visit 
allocation). Only a job with
+    // BOTH kinds can be an unsupported mix, and only then are the two 
below-regular facts
+    // meaningful:
+    //   - all-regular  (no pipelined dep)  => nothing can be 
pipelined-below-regular;
+    //   - all-pipelined (no regular dep)   => no regular boundary to be 
below, or to materialize.
+    // So every job that is not mixed -- which is EVERY job on a deployment 
that never enables the
+    // feature -- costs exactly what it costs without this feature, instead of 
paying for the
+    // (RDD, Boolean)-keyed two-context walk and the boundary map below.
+    val (hasPipelinedKind, hasRegularKind) = classifyJobShuffleKinds(finalRDD)
+    if (!hasPipelinedKind || !hasRegularKind) {
+      return JobShuffleShape(
+        hasPipelined = hasPipelinedKind,
+        hasUnmaterializedRegularBoundary = false,
+        hasPipelinedBelowRegular = false)
+    }
+    var hasPipelined = false
+    var pipelinedBelow = false
+    // Frontier regular shuffle boundaries: those reachable from the final RDD 
WITHOUT crossing
+    // another regular boundary, deduped by shuffle ID. Only these matter for 
the materialization
+    // check (a regular boundary below another one is never a runnable suffix 
member).
+    val regularBoundaries = new HashMap[Int, ShuffleDependency[_, _, _]]
+
+    // ONE walk, carrying `belowRegular` (true once the path from the final 
RDD has crossed a
+    // regular boundary), computes hasPipelined and pipelinedBelow together -- 
replacing the old
+    // per-boundary rddGraphHasPipelinedDependency re-walks (O(K x graph) on 
shared ancestors).
+    // A node reachable BOTH above and below a regular boundary must be 
explored in BOTH contexts:
+    // a pipelined dep under it counts as pipelinedBelow on the below path but 
not on the above
+    // path. So the visited set is keyed on (RDD, belowRegular), NOT on the 
RDD alone -- keying on
+    // the RDD alone would let the first-reached context win and drop the 
other, missing a
+    // pipelined-below-regular dep (a wrongly-accepted job). A node is thus 
visited at most twice,
+    // keeping the cost O(graph) rather than O(K x graph). hasPipelined is set 
only above a regular
+    // boundary, matching the old walk (which stopped at boundaries): a 
below-boundary pipelined
+    // dep is the pipelinedBelow reject case, never a runnable group member.
+    val visited = new HashSet[(RDD[_], Boolean)]
+    val stack = new ListBuffer[(RDD[_], Boolean)]
+    stack += ((finalRDD, false))
+    while (stack.nonEmpty) {
+      val entry = stack.remove(0)
+      val rdd = entry._1
+      val belowRegular = entry._2
+      if (visited.add(entry)) {
+        rdd.dependencies.foreach {
+          case pd: PipelinedShuffleDependency[_, _, _] =>
+            if (belowRegular) pipelinedBelow = true else hasPipelined = true
+            stack.prepend((pd.rdd, belowRegular))
+          case sd: ShuffleDependency[_, _, _] =>
+            // A frontier boundary only when not already below one; descend 
with belowRegular set.
+            if (!belowRegular) regularBoundaries.getOrElseUpdate(sd.shuffleId, 
sd)
+            stack.prepend((sd.rdd, true))
+          case narrowDep =>
+            stack.prepend((narrowDep.rdd, belowRegular))
+        }
+      }
+    }
+
+    // The materialization check only matters for a pipelined job: 
`isUnsupportedMix` consumes
+    // `hasUnmaterializedRegularBoundary` only when `hasPipelined` is true (a 
pipelined shuffle
+    // below an unmaterialized regular boundary is the rejected shape). A job 
with no pipelined
+    // dependency -- every job on a feature-off deployment -- would otherwise 
pay K
+    // getNumAvailableOutputs lookups (a read-locked shuffleStatuses count) 
for a value never read,
+    // on the single-threaded event loop. So skip the loop entirely unless the 
walk saw a pipelined
+    // dependency; a non-pipelined job reports hasUnmaterialized = false 
(unused).
+    var hasUnmaterialized = false
+    if (hasPipelined) {
+      regularBoundaries.values.foreach { sd =>
+        // Materialized means every MAP partition has a registered output: the 
tracker counts map
+        // outputs, so compare against the producer RDD's partition count 
(matching how
+        // ShuffleMapStage.isAvailable derives completeness), not the 
reducer-side partitioner.
+        // This is a point-in-time check at job submission. If a materialized 
prefix's output were
+        // LOST after this classification but before the pipelined suffix 
finished (executor loss),
+        // the prefix would need to re-run while the gang holds all slots -- 
the very deadlock this
+        // shape check forbids. That is safe here for two reasons: (1) the 
only supported deployment
+        // is single-executor local mode, where executor loss does not occur 
in normal operation;
+        // and (2) if a FetchFailed did strip the prefix, handleTaskCompletion 
routes it to a
+        // WHOLE-GROUP abort (the failing stage is a pipelined group member), 
not a lone-stage
+        // resubmit into the held slots -- the job reruns from scratch rather 
than deadlocking.
+        if (mapOutputTracker.getNumAvailableOutputs(sd.shuffleId) != 
sd.rdd.partitions.length) {

Review Comment:
   **"the job reruns from scratch" does not hold; a FetchFailed on the prefix 
poisons the plan.**
   
   A `FetchFailed` raised by pipelined producer P reading materialized prefix R 
takes the group-atomic branch in `handleTaskCompletion` 
(`isPipelinedGroupMember(failedStage)`), which calls 
`unregisterOutputsOnFetchFailedExecutor` -- in local mode that is 
`removeOutputsOnExecutor("driver")`, stripping *every* regular map output 
including R's -- and then `abortStage`s without ever resubmitting R.
   
   On the next submission of the same `Dataset` (AQE reuses the materialized 
`ShuffleQueryStageExec` for R, and the pipelined exchange is kept inline by 
`createNonResultQueryStages`), this check now sees `getNumAvailableOutputs(R) = 
0`, `hasUnmaterialized = true`, and the job is rejected fail-fast with "only 
supported when every regular shuffle is a fully-materialized prefix". The 
Dataset cannot run again until re-planned.
   
   This is reachable in local mode: 
`spark.sql.classic.shuffleDependency.fileCleanup.enabled=true` 
(`RemoveShuffleFiles`) deletes files but deliberately keeps tracker entries "so 
that stage retries would be triggered", and it is the default under 
`Utils.isTesting`. Run a cached mixed-shape Dataset twice: run 2 aborts instead 
of the contractual R recompute, run 3+ is rejected outright. The new test 
"losing a materialized prefix's output mid-group aborts the whole group" only 
asserts the abort. The prefix should be resubmitted (or its outputs left 
intact) when the group is aborted.



##########
core/src/main/scala/org/apache/spark/shuffle/local/pipelined/ChannelShuffleRendezvous.scala:
##########
@@ -0,0 +1,190 @@
+/*
+ * 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.shuffle.local.pipelined
+
+import java.util.concurrent.{ConcurrentHashMap, LinkedBlockingQueue}
+
+import org.apache.spark.{SparkContext, TaskContext}
+
+/**
+ * Process-wide rendezvous between the map (writer) and reduce (reader) sides 
of an
+ * in-process pipelined shuffle. One bounded queue exists per
+ * `(shuffleId, epoch, reducePartitionId)`; every map task writing to a given 
reduce partition
+ * shares the queue with the single reduce task that drains it. Queue elements 
are BATCHES
+ * of records (`Array[AnyRef]` of pairs, see [[ChannelShuffleWriter]]) or the
+ * [[EndOfStream]] marker, so the queue's per-operation lock cost is paid per 
batch, not
+ * per row.
+ *
+ * The `epoch` is the per-run id (the jobId, propagated to both the writer and 
the reader of
+ * one gang via a job-level local property -- see 
`SparkContext.SPARK_PIPELINED_RUN_EPOCH`). A
+ * shuffleId is RE-RUN within one query (a RangePartitioner sample job then 
the main job;
+ * executeTake's per-batch jobs; a classic Dataset re-executing a reused 
plan), and each run is
+ * a different job, hence a different epoch. Keying by epoch makes each run's 
queues and marks
+ * PHYSICALLY separate: the new run never sees a partition whose reader never 
started in the old
+ * run (whose queue still holds stale batches + end-of-stream markers), and a 
straggler writer
+ * from an aborted run -- still looping because a task kill need not interrupt 
the thread -- can
+ * only touch its OWN (old) epoch's queue, never the new run's. This replaces 
an earlier design
+ * that reused one key per shuffleId and tried to reset shared marks between 
runs, which left
+ * stale queues and raced stragglers.
+ *
+ * This is correct only when producer and consumer tasks are co-resident in 
the same JVM,
+ * i.e. a single executor (local mode). The concurrent-stage scheduler 
co-schedules the two
+ * stages so both are running, but it does not by itself guarantee 
co-location; the
+ * [[PipelinedChannelShuffleManager]] is only intended for single-executor 
deployments,
+ * where co-location is automatic. Cross-executor pipelined shuffle is served 
by the RPC
+ * streaming shuffle instead.
+ *
+ * The queues are bounded, so a fast producer blocks on `put` when the 
consumer lags --
+ * this is the backpressure that keeps the pipelined hand-off memory-bounded.
+ */
+private[spark] object ChannelShuffleRendezvous {
+
+  /**
+   * Marker placed on a queue by each map task when it finishes writing to 
that reduce
+   * partition. A reader stops once it has seen one marker per map task.
+   */
+  val EndOfStream: AnyRef = new AnyRef
+
+  /**
+   * The per-run epoch for the current task, read from the job-level local 
property the
+   * DAGScheduler stamps on a pipelined job 
(`SparkContext.SPARK_PIPELINED_RUN_EPOCH` = jobId).
+   * Both the writer and the reader of one gang read this, so they address the 
same per-run
+   * queues. Absent (a core-RDD path that never sets it) defaults to 0; that 
is fine because such
+   * a shuffleId is never re-run into a colliding second live run.
+   */
+  def epochOf(tc: TaskContext): Int =
+    Option(tc)
+      .flatMap(t => 
Option(t.getLocalProperty(SparkContext.SPARK_PIPELINED_RUN_EPOCH)))
+      .map(_.toInt)
+      .getOrElse(0)
+
+  // State is nested by shuffleId FIRST, then keyed by (epoch, 
reducePartitionId) within it. The
+  // outer level exists so the two per-shuffle operations the ContextCleaner 
drives -- holdsShuffle
+  // and removeShuffle -- are O(1) map lookups instead of a scan of every live 
entry: holdsShuffle
+  // now runs for EVERY shuffle cleaned in a feature-on session, including the 
regular prefix
+  // shuffles this feature itself produces, so a flat key made that O(live 
entries) per cleanup.
+  //
+  // Queue values are AnyRef because a queue carries both record batches 
(Array[AnyRef]) and the
+  // EndOfStream marker.
+  private val queues =
+    new ConcurrentHashMap[Int, ConcurrentHashMap[(Int, Int), 
LinkedBlockingQueue[AnyRef]]]()
+
+  // (epoch, reducePartitionId) keys, per shuffleId, whose reader has departed 
(its reduce task
+  // finished) and will drain no more. A writer stops feeding an abandoned 
partition and drops the
+  // rest. This covers the LIVE-partition early-stop case (e.g. a LIMIT reader 
that pulled enough
+  // and quit): without it the writer fills the partition's bounded queue and 
blocks forever.
+  private val abandoned =
+    new ConcurrentHashMap[Int, java.util.Set[(Int, Int)]]()
+
+  /**
+   * Per-queue capacity in BATCHES (not rows), the backpressure bound and the 
heap-residency
+   * knob (see spark.shuffle.channel.queueCapacity). Set once by the channel 
manager at
+   * construction from that conf; defaults to 64 (with the default 1024-row 
batch, ~64K rows per
+   * reduce partition in flight) until a manager sets it. `@volatile` because 
the manager sets it
+   * on the driver while writer/reader threads read it.
+   */
+  @volatile private var capacity = 64
+
+  /** Set the per-queue capacity in batches. Called by the channel manager 
from its conf. */
+  private[pipelined] def setCapacity(batches: Int): Unit = { capacity = 
batches }
+
+  /** The queue for one `(shuffleId, epoch, reducePartitionId)`, created on 
first access. */
+  def queue(shuffleId: Int, epoch: Int, reducePartitionId: Int): 
LinkedBlockingQueue[AnyRef] = {

Review Comment:
   **Leak: per-epoch queues and abandon marks are never released while the 
`Dataset` is alive.**
   
   Queues are created lazily per `(shuffleId, epoch = jobId, pid)`, the 
reader's completion listener calls `abandon` even on normal completion (adding 
a mark; `abandon` clears but does not remove the queue), and the only release 
path is `removeShuffle(shuffleId)`, which `ContextCleaner` drives only when the 
`ShuffleDependency` is garbage-collected.
   
   `ShuffleExchangeExec.shuffleDependency` is a `@transient lazy val` reused 
through the cached `executeRDD` (also under AQE's `withFinalPlanUpdate`), so a 
held `df` keeps the same shuffleId reachable and every `df.collect()` / 
`df.toLocalIterator()` / `df.rdd.count()` adds `numPartitions` fresh 
`LinkedBlockingQueue`s, marks and tuple keys (~80-100 KB per run at 200 
partitions), unbounded, with `holdsShuffle` true forever. `RemoveShuffleFiles` 
masks this in tests but the production default is `DoNotCleanup`. (`df.count()` 
is unaffected since it builds a new `QueryExecution` per call.)
   
   Consider dropping the epoch's entries when the job finishes (e.g. from the 
job-end cleanup that removes the producer stage), or having the reader's 
completion listener remove the queue instead of only clearing it.



##########
sql/core/src/main/scala/org/apache/spark/sql/execution/exchange/EnablePipelinedShuffle.scala:
##########
@@ -0,0 +1,148 @@
+/*
+ * 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.exchange
+
+import org.apache.spark.sql.catalyst.rules.Rule
+import org.apache.spark.sql.execution.{CoalesceExec, CollectLimitExec, 
CollectTailExec, SparkPlan, TakeOrderedAndProjectExec}
+import org.apache.spark.sql.execution.joins.CartesianProductExec
+
+/**
+ * Opt-in (SPARK-57399). Rewrites EVERY [[ShuffleExchangeExec]] in a
+ * batch physical plan to `pipelined = true`, so each shuffle is served by the 
in-process
+ * pipelined channel manager and the concurrent-stage scheduler runs the map 
and reduce stages
+ * together.
+ *
+ * The rewrite is deliberately unconditional rather than cost-based: the 
scheduler requires a job to
+ * be all-pipelined (or a materialized prefix below a pipelined suffix), so 
choosing per exchange
+ * would produce exactly the mixed shapes it rejects. What the rule does 
decide is ELIGIBILITY --
+ * the environment gates in [[PipelinedShuffleEligibility]] and the plan 
shapes below that force the
+ * whole plan back to regular.
+ *
+ * Enabled only when `spark.sql.shuffle.localPipelined.enabled=true`. It runs 
in the non-AQE
+ * `preparations` list, so it also requires AQE to be off (under AQE the plan 
is hidden behind
+ * an opaque `AdaptiveSparkPlanExec` leaf and this rule sees no exchanges).
+ *
+ * Rewriting ALL shuffles (not just hash-partitioning ones) keeps the job 
all-pipelined, which
+ * the DAGScheduler requires: a mix of pipelined and regular shuffles in one 
job is rejected.
+ * SinglePartition and RangePartitioning exchanges pipeline fine -- the 
channel transport only
+ * routes by `partitioner.getPartition(key)` and does not care which 
partitioning produced the
+ * id (SinglePartition is the numPartitions == 1 degenerate case).
+ *
+ * These shapes make the rule leave the whole plan regular:
+ *   - reuse: a pipelined producer with more than one consumer (fan-out) is 
rejected, so if any
+ *     exchange in the plan is reused the rule bails out.
+ *   - an UNSUPPORTED CONSUMER reading a shuffle (see 
[[readsShuffleThroughUnsupportedConsumer]]):
+ *     an operator that would drain a shuffle in a way the channel transport 
cannot serve, or that
+ *     builds its own hidden regular shuffle. If such an operator sits above 
any shuffle the rule
+ *     leaves the WHOLE plan regular (leaving only that one exchange regular 
would put a pipelined
+ *     exchange below a regular boundary, which the scheduler rejects -- so it 
is all-or-nothing).
+ *     The query runs correctly, just not pipelined. The unsupported consumers 
are:
+ *       - `CoalesceExec` (user `.coalesce(n)`): its `CoalescedRDD` makes ONE 
reduce task drain
+ *         SEVERAL reduce partitions sequentially. The single-threaded writer 
parks on a full
+ *         bounded queue filling a later partition before emitting an earlier 
one's markers, so a
+ *         reader draining partitions in order deadlocks the writer with no 
timeout escape;
+ *         `coalesce`'s narrow-merge contract also cannot be honored by 
re-hashing to `n`.
+ *       - `CartesianProductExec`: its `UnsafeCartesianRDD` reads each left 
(child) partition once
+ *         per right partition, so N reduce tasks mint N readers on the SAME 
rendezvous queue for
+ *         one `(shuffleId, epoch, pid)` -- rows and end-of-stream markers 
split
+ *         nondeterministically (wrong results), a reader short of `numMaps` 
markers hangs, and
+ *         the first to finish abandons the queue and discards the others' 
data. The fan-out check
+ *         does not catch it (one consumer RDD, computed many times), nor the 
width-1 require.
+ *       - `CollectLimitExec` / `CollectTailExec` / 
`TakeOrderedAndProjectExec`: each builds a
+ *         hidden regular (`pipelined = false`) shuffle inside `doExecute` via
+ *         `prepareShuffleDependency`, invisible to this plan walk. A flipped 
exchange below one of
+ *         them would sit under that unmaterialized regular boundary and the 
job would hard-fail at
+ *         submission (`classifyJobShuffleShape`'s pipelined-below-regular 
rejection). (`.collect()`
+ *         on a limit takes `executeTake` and never hits `doExecute`; `.write` 
/ `.toLocalIterator`
+ *         / a non-root position do.)
+ */
+object EnablePipelinedShuffle extends Rule[SparkPlan] {
+
+  override def apply(plan: SparkPlan): SparkPlan = {
+    // Shared environment gate (opt-in flag, single-executor local mode, 
channel manager active),
+    // identical to the AQE rule's -- see PipelinedShuffleEligibility for why 
it is a correctness
+    // gate. It also requires AQE off implicitly: under AQE this rule sees no 
exchanges.
+    if (!PipelinedShuffleEligibility.enabled(plan, conf)) return plan
+
+    val shuffles = plan.collect { case s: ShuffleExchangeExec => s }
+    if (shuffles.isEmpty) return plan
+
+    // A reused exchange has more than one consumer; a pipelined producer 
cannot fan out, so
+    // leave the whole plan regular rather than produce a rejected job. Check 
subquery plans
+    // too (plan.exists walks the operator tree only): today no SQL shape can 
place a reused
+    // PIPELINED exchange there -- same-tree reuse is caught here, 
main-vs-subquery reuse
+    // never fires because the subquery's own preparation pass (PlanSubqueries 
->
+    // prepareExecutedPlan, which includes this rule) flips its exchanges 
pipelined BEFORE
+    // the outer ReuseExchangeAndSubquery compares canonical forms, and 
subquery-vs-subquery
+    // duplication is collapsed by MergeScalarSubqueries / subquery reuse 
first -- but the
+    // second mechanism is an accident of rule ordering and the third is 
optimizer behavior,
+    // so this gate does not rely on either.
+    if (plan.collectWithSubqueries { case r: ReusedExchangeExec => r 
}.nonEmpty) {
+      // Not a warning: this is a normal, expected fallback (reuse is routine 
optimizer output,
+      // e.g. self-joins), the query still runs correctly as a regular 
shuffle, and the user has
+      // nothing to act on. Log at DEBUG as diagnostic ("why this query did 
not go pipelined")
+      // rather than WARN, which would fire on every reuse-bearing query and 
read as a fault.
+      logDebug("EnablePipelinedShuffle: plan has a reused exchange; leaving it 
regular to " +
+        "avoid a fan-out pipelined job.")
+      return plan
+    }
+
+    // An operator that would read a shuffle in a way the channel transport 
cannot serve, or that
+    // builds its own hidden regular shuffle, forces the whole plan regular 
(see class doc). Like
+    // the reuse fallback this is a normal, expected outcome, so log at DEBUG 
rather than WARN.
+    if (readsShuffleThroughUnsupportedConsumer(plan)) {
+      logDebug("EnablePipelinedShuffle: a shuffle is read through an operator 
the channel " +
+        "transport cannot serve (coalesce / cartesian product / a limit 
operator that builds a " +
+        "hidden shuffle); leaving the plan regular.")
+      return plan
+    }
+
+    plan.transformUp {

Review Comment:
   **Hard failure: a regular RDD shuffle on top of `Dataset.rdd` is rejected 
with no fallback.**
   
   The rule flips every exchange it can see, but it cannot see consumers 
layered on `toRdd`. Any RDD-API shuffle above the pipelined `ShuffledRowRDD` 
yields a pipelined-below-regular job:
   ```scala
   df.groupBy("k").count().rdd.map(r => (r.getLong(0) % 2, 1L)).reduceByKey(_ + 
_).collect()
   ```
   (likewise `df.rdd.repartition(4)`, `sortByKey`, MLlib `treeAggregate` -> 
`foldByKey`). `classifyJobShuffleShape` sets `hasPipelinedBelowRegular` and 
`handleJobSubmitted` fails the job with "A job that mixes a pipelined shuffle 
dependency with a regular shuffle dependency is only supported when ...". 
`AQEEnablePipelinedShuffle` flips the same exchange as a free candidate.
   
   The scaladoc's all-or-nothing rationale ("leaving only that one exchange 
regular would put a pipelined exchange below a regular boundary") is exactly 
what an RDD-API shuffle violates, and neither the rules nor the scheduler can 
honor it for that consumer. Suggest treating a `DeserializeToObjectExec` root 
as an unsupported consumer (leave the plan regular), since that is the 
`Dataset.rdd` / `toJavaRDD` boundary. Not covered by any test.



##########
sql/core/src/main/scala/org/apache/spark/sql/execution/exchange/ShuffleExchangeExec.scala:
##########
@@ -570,7 +569,15 @@ object ShuffleExchangeExec {
           rddWithPartitionIds,
           new PartitionIdPassthrough(part.numPartitions),
           serializer,
-          shuffleWriterProcessor = createShuffleWriteProcessor(writeMetrics),
+          // Copy rows only for a transport that hands object references to a 
concurrent
+          // consumer (the in-process channel). The RPC streaming transport 
detaches rows by
+          // serializing them promptly and must not pay an extra per-row copy 
on its path. And
+          // skip it when rddWithPartitionIds already copied: 
needToCopyObjectsBeforeShuffle makes
+          // that RDD emit (pid, row.copy()), so a second copy here would be 
redundant.
+          shuffleWriterProcessor = createShuffleWriteProcessor(
+            writeMetrics,
+            copyRows = 
SparkEnv.get.pipelinedShuffleManager.requiresDetachedRecords &&

Review Comment:
   **Nit (reuse): second row-copy site, and the new comment inside 
`needToCopyObjectsBeforeShuffle` is now false.**
   
   Rows are already copied in `rddWithPartitionIds` (L535) when 
`needToCopyObjectsBeforeShuffle(part)` is true; this adds a second copy site 
inside the write processor keyed on `requiresDetachedRecords && 
!needToCopyObjectsBeforeShuffle(part)`. The channel path's copy decision thus 
depends on the *blocking* manager's sort/bypass thresholds, an unrelated proxy 
for "did the earlier map already copy". The comment added at L315-317 ("A 
pipelined shuffle ... does not go through here") is contradicted by the two 
calls on this path.
   
   Giving `needToCopyObjectsBeforeShuffle` a `pipelined: Boolean` parameter 
that returns `true` when `pipelinedShuffleManager.requiresDetachedRecords` 
would keep a single copy site and let `copyRows`, the `write` override and the 
comment go.



##########
core/src/main/scala/org/apache/spark/shuffle/local/pipelined/PipelinedChannelShuffleManager.scala:
##########
@@ -0,0 +1,154 @@
+/*
+ * 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.shuffle.local.pipelined
+
+import org.apache.spark.{ShuffleDependency, SparkConf, TaskContext}
+import org.apache.spark.internal.config
+import org.apache.spark.shuffle.{BaseShuffleHandle, PipelinedShuffleManager, 
ShuffleHandle, ShuffleReader, ShuffleReadMetricsReporter, 
ShuffleWriteMetricsReporter, ShuffleWriter}
+
+/**
+ * A pipelined shuffle manager whose writer -> reader transport is an 
in-process bounded
+ * channel (see [[ChannelShuffleRendezvous]]) rather than the RPC streaming 
shuffle. It
+ * serves a [[org.apache.spark.PipelinedShuffleDependency]] on a single 
executor, letting
+ * the concurrent-stage scheduler run a shuffle's map and reduce stages at the 
same time
+ * while records flow between them in memory -- the in-process pipelined 
shuffle execution model.
+ *
+ * Selected via `spark.shuffle.manager.incremental`.
+ *
+ * Unlike the RPC streaming manager, this one needs no 
`StreamingShuffleOutputTracker`: it
+ * finds each reader/writer pair through the JVM-local 
[[ChannelShuffleRendezvous]] rather
+ * than a directory of writer host/port locations. It therefore declares
+ * `usesStreamingShuffleOutputTracker = false`, so `SparkEnv` creates no 
tracker and the
+ * scheduler registers the shuffle with none (a pipelined stage's availability 
is tracked on
+ * the stage itself, not in any output tracker). This is why it implements the
+ * `PipelinedShuffleManager` trait directly instead of subclassing the 
concrete streaming
+ * manager.
+ *
+ * This manager deliberately keeps NO per-shuffle registry. An early version 
recorded each
+ * shuffle's map-task count at registration and looked it up in getReader -- 
and lost it when
+ * an unregisterShuffle arrived BETWEEN registration and the job running, 
which happens
+ * legitimately: Dataset.rdd builds the RDD inside a SQL execution scope that 
ends (and, with
+ * spark.sql.classic.shuffleDependency.fileCleanup.enabled, removes the 
shuffle from every
+ * manager) before any job has run. The reader then saw a missing entry as 
numMaps = 0 and
+ * silently under-read the channel. The count is instead stamped into the 
shuffle handle at
+ * registration ([[ChannelShuffleHandle.numMaps]]): the handle travels with 
the dependency
+ * into every task, a plain Int field survives task serialization (the 
dependency's own `rdd`
+ * reference is @transient and is null inside a deserialized task), and no 
later unregister
+ * can take it away.
+ */
+private[spark] class PipelinedChannelShuffleManager(conf: SparkConf)
+  extends PipelinedShuffleManager {
+
+  // The in-process rendezvous is JVM-local: on a multi-executor deployment 
each executor would
+  // get its own empty queue map, and every reader would block forever on data 
written in some
+  // other JVM -- a silent hang. Refuse to construct anywhere but local mode, 
so a
+  // misconfiguration fails loudly at startup instead.
+  require(org.apache.spark.util.Utils.isLocalMaster(conf),
+    "PipelinedChannelShuffleManager is an in-process (single-JVM) transport 
and requires " +
+      s"local mode; got master '${conf.get("spark.master", "")}'")
+
+  // Rows accumulated per output partition before a batch is handed across the 
channel in one
+  // queue operation. Batching amortizes the queue's per-operation lock cost; 
per-row hand-off
+  // measured ~19x slower than a regular shuffle on a 20M-row repartition.
+  private val batchSize = conf.get(config.SHUFFLE_PIPELINED_CHANNEL_BATCH_SIZE)
+
+  // Per-queue depth in batches (backpressure bound + heap-residency knob). 
Set the process-wide
+  // rendezvous from the conf at construction, before any writer/reader 
creates a queue.
+  
ChannelShuffleRendezvous.setCapacity(conf.get(config.SHUFFLE_PIPELINED_CHANNEL_QUEUE_CAPACITY))
+
+  override def usesStreamingShuffleOutputTracker: Boolean = false

Review Comment:
   **Multi-job consumers re-run the entire producer once per job.**
   
   Because no tracker retains a pipelined shuffle's outputs and availability 
lives only in the stage-local `pipelinedCompletedPartitions`, a re-created 
producer stage always starts from zero, whereas a regular shuffle re-creates 
its stage already available and skips it. Every job over the same pipelined 
shuffle therefore re-runs the whole producer subtree:
   
   - `Dataset.toLocalIterator` (`executeToIterator` issues one job per 
partition, and the rule's scaladoc explicitly lists `.toLocalIterator` as a 
supported path): with 200 shuffle partitions the scan/join/partial-aggregate 
producer runs 200 times, side-effecting UDFs fire 200 times.
   - `df.rdd.take(n)` (growing batches), `df.rdd.toLocalIterator`, the 
`RangePartitioner` sample job + main job for `orderBy` over an upstream 
pipelined shuffle, `BroadcastNestedLoopJoin`'s `executeTake(1)` probe.
   
   The live-reduce hint restricts what the producer *emits*, not what it 
*computes*. Only the limit-operator consumers are excluded by the rules; 
nothing documents the N-x cost and no test exercises these paths. Worth either 
excluding `toLocalIterator`-style consumers or documenting the cost in the 
config description.



##########
sql/core/src/main/scala/org/apache/spark/sql/execution/adaptive/AQEEnablePipelinedShuffle.scala:
##########
@@ -0,0 +1,229 @@
+/*
+ * 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.adaptive
+
+import scala.collection.mutable
+
+import org.apache.spark.sql.catalyst.rules.Rule
+import org.apache.spark.sql.execution.{BinaryExecNode, CoalesceExec, 
CollectLimitExec, CollectTailExec, SparkPlan, TakeOrderedAndProjectExec}
+import org.apache.spark.sql.execution.exchange.{PipelinedShuffleEligibility, 
ReusedExchangeExec, ShuffleExchangeExec}
+import org.apache.spark.sql.execution.joins.ShuffledJoin
+
+/**
+ * Opt-in (SPARK-57399). Flips eligible [[ShuffleExchangeExec]]
+ * nodes to `pipelined = true` under AQE, the adaptive counterpart of the 
non-AQE
+ * `EnablePipelinedShuffle` preparation rule (which is a no-op once the plan 
is wrapped in
+ * `AdaptiveSparkPlanExec`). Runs in 
`AdaptiveSparkPlanExec.queryStagePreparationRules`, so
+ * it is re-applied on every replanning round; the decision is deterministic 
on plan shape,
+ * and already-flipped exchanges are left alone.
+ *
+ * Placement policy. A flipped exchange has no
+ * map output statistics (it never materializes as a query stage: the 
DAGScheduler
+ * gang-runs it inline with its consumer in the final job -- see the pipelined 
case in
+ * `AdaptiveSparkPlanExec.createNonResultQueryStages`), so only exchanges 
whose statistics
+ * no AQE decision consumes are flipped:
+ *
+ *   - "free" candidate: the path from the candidate to the plan root crosses 
no
+ *     stats-sensitive node ([[BinaryExecNode]], another 
[[ShuffleExchangeExec]], or a
+ *     query stage). Its own coalescing/skew handling is given up; nothing 
above needed its
+ *     stats.
+ *   - "join-paired" candidate: the immediate shuffle inputs of a 
[[ShuffledJoin]] whose
+ *     path to the root is otherwise free, flipped only as a symmetric pair 
(an asymmetric
+ *     flip would leave one side participating in AQE coalesce/skew and the 
other fixed).
+ *   - everything else stays regular and materializes as usual -- those stages 
form the
+ *     fully-materialized prefix the scheduler's mixed-job shape requires.
+ *
+ * A pipelined exchange supports every
+ * partitioning, so a SinglePartition exchange in a free position is simply a 
candidate
+ * itself. The walk stops below a flipped candidate: exchanges underneath stay 
regular and
+ * keep full AQE treatment. Candidates whose canonicalized form occurs more 
than once in
+ * the plan (including inside materialized stages and subqueries) are skipped: 
flipping
+ * them would trade AQE's stage reuse for duplicate recomputation, and a 
pipelined producer
+ * cannot be consumed twice.
+ */
+object AQEEnablePipelinedShuffle extends Rule[SparkPlan] {
+
+  override def apply(plan: SparkPlan): SparkPlan = {
+    // Shared environment gate (opt-in flag, single-executor local mode, 
channel manager active),
+    // identical to the non-AQE rule's -- see PipelinedShuffleEligibility for 
why it is a
+    // correctness gate that must not drift between the two rules.
+    if (!PipelinedShuffleEligibility.enabled(plan, conf)) return plan
+
+    flipEligibleExchanges(plan)
+  }
+
+  /**
+   * The plan-shape core of the rule, factored out of [[apply]]'s environment 
guards (opt-in flag,
+   * local mode, channel manager) so it can be unit-tested on a hand-built 
plan directly. Collects
+   * the eligible exchanges and returns the plan with each flipped to 
`pipelined = true`.
+   */
+  private[adaptive] def flipEligibleExchanges(plan: SparkPlan): SparkPlan = {
+    val shared = if (conf.exchangeReuseEnabled) duplicatedShuffleForms(plan) 
else Set.empty[Any]
+    // Collect the exchanges to flip BY IDENTITY (SparkPlan.id, unique per 
instance), not by the
+    // node itself: TreeNode overrides hashCode but not equals, so a 
HashSet[ShuffleExchangeExec]
+    // matches structurally, and the transformDown below would then flip EVERY 
exchange
+    // structurally equal to a collected one -- including a twin the collector 
deliberately left
+    // regular on a blocked path. That twin, if it sits below a regular 
boundary, makes
+    // classifyJobShuffleShape reject the whole job. Keying on the instance id 
flips exactly the
+    // nodes the collector chose, regardless of spark.sql.exchange.reuse 
(duplicatedShuffleForms,
+    // the only other guard, is empty when reuse is off). transformDown 
matches each ORIGINAL node
+    // before rebuilding it, so its id is the same instance id the collector 
recorded.
+    val toFlip = mutable.HashSet.empty[Int]
+    collectCandidates(plan, blocked = false, shared, toFlip)
+    if (toFlip.isEmpty) return plan
+
+    // transformDown, NOT transformUp: candidates can be nested (a 
SinglePartition candidate
+    // above a hash candidate). transformUp rebuilds children first, so by the 
time it
+    // reaches the upper candidate that node is a NEW instance whose (already 
flipped) child
+    // no longer matches the collected original structurally, and the upper 
flip is silently
+    // dropped -- leaving a regular exchange above a pipelined one, which the 
scheduler then
+    // rejects. transformDown hands each candidate to the pattern before its 
subtree is
+    // rebuilt, so both nested flips apply.
+    plan.transformDown {
+      case s: ShuffleExchangeExec if toFlip.contains(s.id) => s.copy(pipelined 
= true)
+    }
+  }
+
+  private def isCandidate(s: ShuffleExchangeExec, shared: Set[Any]): Boolean =

Review Comment:
   **Usability: with default `spark.sql.shuffle.partitions=200`, most 
aggregations fail at admission.**
   
   A pipelined exchange is never promoted to a `ShuffleQueryStageExec`, so 
`CoalesceShufflePartitions` no longer applies, and neither rule compares the 
exchange width against `sc.maxNumConcurrentTasks`. With both opt-in configs set 
and everything else default (`local[8]`, 200 partitions):
   ```sql
   SELECT k, count(*) FROM t GROUP BY k
   ```
   has gang demand 200 + N > 8 and fails with 
`CONCURRENT_SCHEDULER_INSUFFICIENT_SLOT`, whereas with the flag off AQE 
coalesces 200 down to a few and the query succeeds. I understand fail-loud is a 
deliberate decision, but the config doc does not tell users they must set 
`spark.sql.shuffle.partitions` below the local core count, and every suite 
hides this by pinning `local[16]` with 4 partitions. As written there is no 
configuration where enabling the feature works without retuning. A width check 
here (skip the flip and log, like the other shape fallbacks) or an explicit 
note in the `SQLConf` doc would help.



##########
sql/core/src/main/scala/org/apache/spark/sql/execution/adaptive/AQEEnablePipelinedShuffle.scala:
##########
@@ -0,0 +1,229 @@
+/*
+ * 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.adaptive
+
+import scala.collection.mutable
+
+import org.apache.spark.sql.catalyst.rules.Rule
+import org.apache.spark.sql.execution.{BinaryExecNode, CoalesceExec, 
CollectLimitExec, CollectTailExec, SparkPlan, TakeOrderedAndProjectExec}
+import org.apache.spark.sql.execution.exchange.{PipelinedShuffleEligibility, 
ReusedExchangeExec, ShuffleExchangeExec}
+import org.apache.spark.sql.execution.joins.ShuffledJoin
+
+/**
+ * Opt-in (SPARK-57399). Flips eligible [[ShuffleExchangeExec]]
+ * nodes to `pipelined = true` under AQE, the adaptive counterpart of the 
non-AQE
+ * `EnablePipelinedShuffle` preparation rule (which is a no-op once the plan 
is wrapped in
+ * `AdaptiveSparkPlanExec`). Runs in 
`AdaptiveSparkPlanExec.queryStagePreparationRules`, so
+ * it is re-applied on every replanning round; the decision is deterministic 
on plan shape,
+ * and already-flipped exchanges are left alone.
+ *
+ * Placement policy. A flipped exchange has no
+ * map output statistics (it never materializes as a query stage: the 
DAGScheduler
+ * gang-runs it inline with its consumer in the final job -- see the pipelined 
case in
+ * `AdaptiveSparkPlanExec.createNonResultQueryStages`), so only exchanges 
whose statistics
+ * no AQE decision consumes are flipped:
+ *
+ *   - "free" candidate: the path from the candidate to the plan root crosses 
no
+ *     stats-sensitive node ([[BinaryExecNode]], another 
[[ShuffleExchangeExec]], or a
+ *     query stage). Its own coalescing/skew handling is given up; nothing 
above needed its
+ *     stats.
+ *   - "join-paired" candidate: the immediate shuffle inputs of a 
[[ShuffledJoin]] whose
+ *     path to the root is otherwise free, flipped only as a symmetric pair 
(an asymmetric
+ *     flip would leave one side participating in AQE coalesce/skew and the 
other fixed).
+ *   - everything else stays regular and materializes as usual -- those stages 
form the
+ *     fully-materialized prefix the scheduler's mixed-job shape requires.
+ *
+ * A pipelined exchange supports every
+ * partitioning, so a SinglePartition exchange in a free position is simply a 
candidate
+ * itself. The walk stops below a flipped candidate: exchanges underneath stay 
regular and
+ * keep full AQE treatment. Candidates whose canonicalized form occurs more 
than once in
+ * the plan (including inside materialized stages and subqueries) are skipped: 
flipping
+ * them would trade AQE's stage reuse for duplicate recomputation, and a 
pipelined producer
+ * cannot be consumed twice.
+ */
+object AQEEnablePipelinedShuffle extends Rule[SparkPlan] {
+
+  override def apply(plan: SparkPlan): SparkPlan = {
+    // Shared environment gate (opt-in flag, single-executor local mode, 
channel manager active),
+    // identical to the non-AQE rule's -- see PipelinedShuffleEligibility for 
why it is a
+    // correctness gate that must not drift between the two rules.
+    if (!PipelinedShuffleEligibility.enabled(plan, conf)) return plan
+
+    flipEligibleExchanges(plan)
+  }
+
+  /**
+   * The plan-shape core of the rule, factored out of [[apply]]'s environment 
guards (opt-in flag,
+   * local mode, channel manager) so it can be unit-tested on a hand-built 
plan directly. Collects
+   * the eligible exchanges and returns the plan with each flipped to 
`pipelined = true`.
+   */
+  private[adaptive] def flipEligibleExchanges(plan: SparkPlan): SparkPlan = {
+    val shared = if (conf.exchangeReuseEnabled) duplicatedShuffleForms(plan) 
else Set.empty[Any]
+    // Collect the exchanges to flip BY IDENTITY (SparkPlan.id, unique per 
instance), not by the
+    // node itself: TreeNode overrides hashCode but not equals, so a 
HashSet[ShuffleExchangeExec]
+    // matches structurally, and the transformDown below would then flip EVERY 
exchange
+    // structurally equal to a collected one -- including a twin the collector 
deliberately left
+    // regular on a blocked path. That twin, if it sits below a regular 
boundary, makes
+    // classifyJobShuffleShape reject the whole job. Keying on the instance id 
flips exactly the
+    // nodes the collector chose, regardless of spark.sql.exchange.reuse 
(duplicatedShuffleForms,
+    // the only other guard, is empty when reuse is off). transformDown 
matches each ORIGINAL node
+    // before rebuilding it, so its id is the same instance id the collector 
recorded.
+    val toFlip = mutable.HashSet.empty[Int]
+    collectCandidates(plan, blocked = false, shared, toFlip)
+    if (toFlip.isEmpty) return plan
+
+    // transformDown, NOT transformUp: candidates can be nested (a 
SinglePartition candidate
+    // above a hash candidate). transformUp rebuilds children first, so by the 
time it
+    // reaches the upper candidate that node is a NEW instance whose (already 
flipped) child
+    // no longer matches the collected original structurally, and the upper 
flip is silently
+    // dropped -- leaving a regular exchange above a pipelined one, which the 
scheduler then
+    // rejects. transformDown hands each candidate to the pattern before its 
subtree is
+    // rebuilt, so both nested flips apply.
+    plan.transformDown {
+      case s: ShuffleExchangeExec if toFlip.contains(s.id) => s.copy(pipelined 
= true)
+    }
+  }
+
+  private def isCandidate(s: ShuffleExchangeExec, shared: Set[Any]): Boolean =
+    !s.pipelined && !shared.contains(s.canonicalized)
+
+  /**
+   * Top-down walk collecting exchanges to flip. `blocked` is true once the 
path from the
+   * root has crossed a stats-sensitive node.
+   */
+  private def collectCandidates(
+      plan: SparkPlan,
+      blocked: Boolean,
+      shared: Set[Any],
+      out: mutable.HashSet[Int]): Unit = plan match {
+    case s: ShuffleExchangeExec =>
+      val flipped = !blocked && isCandidate(s, shared)
+      if (flipped) {
+        out += s.id
+      }
+      // A flipped SinglePartition exchange keeps the walk going: AQE makes no 
decision at
+      // it (it cannot be coalesced or skew-split), so free candidates BELOW 
it flip too,
+      // forming a pipelined chain: all exchanges in such a chain must flip 
together (a
+      // SinglePartition exchange cannot be coalesced or skew-split, so AQE 
makes no decision
+      // at it and free candidates below it flip too). Below any OTHER 
exchange (flipped or
+      // not) the walk stops: what is underneath either materializes as the 
prefix or feeds
+      // a regular exchange whose stats AQE uses, and keeps full AQE treatment 
either way.
+      if (flipped &&
+          s.outputPartitioning == 
org.apache.spark.sql.catalyst.plans.physical.SinglePartition) {
+        collectCandidates(s.child, blocked = false, shared, out)
+      }
+
+    case _: QueryStageExec => // already materialized; a leaf here
+
+    case j: ShuffledJoin if !blocked =>
+      // Flip the join's immediate shuffle inputs only as a symmetric pair.
+      val leftCandidate = immediateShuffleInput(j.left, shared)
+      val rightCandidate = immediateShuffleInput(j.right, shared)
+      (leftCandidate, rightCandidate) match {
+        case (Some(l), Some(r)) =>
+          out += l.id
+          out += r.id
+        case _ => // asymmetric (a broadcast side, a materialized stage, no 
clean input): skip
+      }
+      // Anything deeper is below a join input; blocked either way.
+
+    case p if isStatsSensitive(p) =>
+      p.children.foreach(collectCandidates(_, blocked = true, shared, out))
+
+    case p =>
+      p.children.foreach(collectCandidates(_, blocked, shared, out))
+  }
+
+  /**
+   * The single eligible [[ShuffleExchangeExec]] at the top of one join input, 
looking
+   * through unary non-stats-sensitive forwarders. None if the input is 
anything else.
+   */
+  private def immediateShuffleInput(
+      plan: SparkPlan,
+      shared: Set[Any]): Option[ShuffleExchangeExec] = plan match {
+    case s: ShuffleExchangeExec => Some(s).filter(isCandidate(_, shared))
+    case _: QueryStageExec => None
+    case p if isStatsSensitive(p) => None
+    case p if p.children.size == 1 => immediateShuffleInput(p.children.head, 
shared)
+    case _ => None
+  }
+
+  /**
+   * Nodes below which a candidate exchange must NOT be flipped. Reasons a 
node lands here:
+   *   - AQE consumes map output statistics from the stages below it 
([[BinaryExecNode]], another
+   *     [[ShuffleExchangeExec]]); flipping below it would give up stats a 
decision needs.
+   *   - [[CoalesceExec]] reads its child shuffle multi-partition-per-task (a 
`CoalescedRDD` over
+   *     the `ShuffledRowRDD`), which the channel transport cannot serve; the 
shuffle it reads
+   *     must stay regular.
+   *   - the limit operators [[CollectLimitExec]] / [[CollectTailExec]] /
+   *     [[TakeOrderedAndProjectExec]] each build a hidden regular (`pipelined 
= false`) shuffle
+   *     inside `doExecute` (via `prepareShuffleDependency`) that no plan walk 
can see; a flipped
+   *     exchange below one of them would sit under that unmaterialized 
regular boundary and the
+   *     job would hard-fail at submission (pipelined-below-regular).
+   * Blocking here keeps the shuffle below -- and everything deeper -- 
regular, so no pipelined
+   * exchange ends up below the operator's regular boundary. See the non-AQE
+   * `EnablePipelinedShuffle` for the full rationale on each operator.
+   */
+  private def isStatsSensitive(plan: SparkPlan): Boolean = plan match {

Review Comment:
   **Nit (reuse): the transport-unsupported operator set is duplicated across 
the two rules.**
   
   `EnablePipelinedShuffle.isUnsupportedConsumer` lists `CoalesceExec | 
CartesianProductExec | CollectLimitExec | CollectTailExec | 
TakeOrderedAndProjectExec`; this method repeats `CoalesceExec` and the three 
limit operators and only catches `CartesianProductExec` incidentally through 
the `BinaryExecNode` stats case. `PipelinedShuffleEligibility` was introduced 
precisely so the two rules cannot drift, so this set belongs there as 
`isUnsupportedConsumer(plan)`, with `isStatsSensitive` becoming `BinaryExecNode 
|| ShuffleExchangeExec || PipelinedShuffleEligibility.isUnsupportedConsumer(p)`.



##########
core/src/main/scala/org/apache/spark/scheduler/DAGScheduler.scala:
##########
@@ -2129,8 +2428,24 @@ private[spark] class DAGScheduler(
     // Job submitted, clear internal data.
     barrierJobIdToNumTasksCheckFailures.remove(jobId)
 
+    // For a pipelined job, stamp the per-run epoch (the jobId) into the job's 
properties BEFORE
+    // creating the ActiveJob, so submitMissingTasks -- which clones 
jobIdToActiveJob(jobId)
+    // .properties per stage -- carries the SAME epoch to every stage's tasks. 
Both the producer
+    // (writer) and the consumer (reader) of the one gang belong to this job, 
so both read one
+    // value; a different run is a different job, hence a different epoch, 
which keys the
+    // in-process channel rendezvous per run (see SPARK_PIPELINED_RUN_EPOCH). 
Copy the caller's
+    // Properties rather than mutating it. Inert for a non-pipelined job 
(property never set,
+    // never read).
+    val jobProperties =
+      if (hasPipelined && pipelinedManagerWantsLiveReduceHints) {
+        val p = Utils.cloneProperties(if (properties == null) new Properties() 
else properties)
+        p.setProperty(SparkContext.SPARK_PIPELINED_RUN_EPOCH, jobId.toString)

Review Comment:
   **Behavior change: concurrent actions on one `Dataset` fail with 
`PIPELINED_SHUFFLE_CROSS_JOB_REUSE`.**
   
   Two concurrent actions on the same `Dataset` share one lazy 
`shuffleDependency`. The second job's `createResultStage` -> 
`getOrCreateShuffleMapStage` finds the producer still bound to the first job 
(`stage.jobIds = {job1}`) and throws `PIPELINED_SHUFFLE_CROSS_JOB_REUSE` 
(0A000):
   ```scala
   val df = spark.table("t").groupBy("k").count()
   Seq(1, 2).par.foreach(_ => df.collect())   // second call fails while the 
first completes
   ```
   The guard itself predates this PR, but it was only reachable through 
Real-Time Mode (a fresh plan per micro-batch). Extending pipelined shuffles to 
batch SQL exposes it to shared DataFrames in notebooks, Thrift Server sessions 
and thread pools, where cross-job stage sharing is otherwise normal. Sequential 
re-execution is fine (job cleanup removes the stage before the `JobWaiter` 
wakes). AQE hits the same path via `ResultQueryStageExec` re-creation over the 
same `optimizedPlan`.
   
   At minimum this deserves a SQL-level test and a note in the config doc; a 
cleaner fix would be to key the run epoch by stage attempt rather than job so a 
second job can wait for or share the running producer.



-- 
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.

To unsubscribe, e-mail: [email protected]

For queries about this service, please contact Infrastructure at:
[email protected]


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

Reply via email to