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


##########
core/src/main/scala/org/apache/spark/ContextCleaner.scala:
##########
@@ -262,7 +262,23 @@ private[spark] class ContextCleaner(
         listeners.asScala.foreach(_.shuffleCleaned(shuffleId))
         logDebug("Cleaned pipelined shuffle " + shuffleId)
       } else {
-        logDebug("Asked to cleanup non-existent shuffle (maybe it was already 
removed)")
+        // The shuffle is in NEITHER tracker. This is either a genuinely 
non-existent shuffle
+        // (already removed) OR an in-process pipelined shuffle whose manager 
keeps NO output
+        // tracker 
(PipelinedChannelShuffleManager.usesStreamingShuffleOutputTracker = false):
+        // such a shuffle registers with no tracker at all, so the two 
branches above miss it,
+        // yet its process-wide rendezvous queues (ChannelShuffleRendezvous) 
still need freeing.
+        // Call shuffleDriverComponents.removeShuffle unconditionally: the 
RemoveShuffle it issues
+        // routes to SparkEnv.unregisterShuffleFromAllManagers, which reaches 
that manager's
+        // unregisterShuffle (-> ChannelShuffleRendezvous.removeShuffle) and 
frees the queues.
+        // Safe for a truly non-existent id: 
BlockManagerMasterEndpoint.removeShuffle finds no
+        // blocks and unregisterShuffle for an unknown id is a no-op on every 
manager. No
+        // shuffle-manager type check here on purpose -- the cleaner stays 
transport-agnostic and
+        // just balances the registerShuffle the ShuffleDependency constructor 
issues for every
+        // shuffle.
+        logDebug("Cleaning tracker-less shuffle " + shuffleId)
+        shuffleDriverComponents.removeShuffle(shuffleId, blocking)

Review Comment:
   This is the one change in the PR that is not behind the opt-in flag, and it 
changes behavior for regular shuffles.
   
   The previous code logged and did nothing here. Now every shuffle id that is 
in neither tracker issues a `RemoveShuffle` (a broadcast to all executors via 
`BlockManagerMasterEndpoint`) and fires `shuffleCleaned` on every registered 
`CleanerListener`.
   
   That branch is reached for already-cleaned shuffles, not just tracker-less 
pipelined ones. `RDD.cleanShuffleDependencies` (a public `@DeveloperApi`, and 
the path `spark.sql.classic.shuffleDependency.fileCleanup.enabled` takes at the 
end of a classic SQL execution) calls `doCleanupShuffle` eagerly, which 
unregisters from the `MapOutputTracker`. When the `ShuffleDependency` is later 
collected, the reference queue calls `doCleanupShuffle` a second time — and now 
lands here. So an ordinary deployment with the feature off gets a duplicate 
cluster-wide `RemoveShuffle` RPC and a duplicate `shuffleCleaned` callback per 
eagerly-cleaned shuffle.
   
   This contradicts "No behavior change by default" in the PR description. The 
comment says the absence of a manager-type check is deliberate to keep the 
cleaner transport-agnostic, which I understand — but the cost is a change that 
reaches users who never enable this. Gating on 
`!SparkEnv.get.pipelinedShuffleManager.usesStreamingShuffleOutputTracker`, or 
having the channel manager expose the set of shuffle ids it holds queues for, 
would keep the new call on the pipelined path only.



##########
core/src/main/scala/org/apache/spark/shuffle/local/pipelined/ChannelShuffleWriterReader.scala:
##########
@@ -0,0 +1,280 @@
+/*
+ * 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.Arrays
+
+import org.apache.spark.{SparkContext, SparkEnv, TaskContext}
+import org.apache.spark.scheduler.MapStatus
+import org.apache.spark.shuffle.{BaseShuffleHandle, ShuffleReader, 
ShuffleReadMetricsReporter, ShuffleWriteMetricsReporter, ShuffleWriter}
+
+/**
+ * Map-side of the in-process pipelined shuffle. Each input record is routed 
to the reduce
+ * partition its key hashes to and accumulated in a per-partition batch; a 
FULL batch (an
+ * `Array[AnyRef]` of `batchSize` pairs) is pushed onto that partition's 
shared queue in one
+ * queue operation (see [[ChannelShuffleRendezvous]]) -- the consumer stage, 
running
+ * concurrently, drains it batch by batch. No serialization, no disk, no 
network.
+ *
+ * Batching is what makes the transport viable for large unaggregated 
shuffles: the queue
+ * costs a lock acquisition per operation (~hundreds of ns under 
producer/consumer
+ * contention), so handing rows across one at a time costs that PER ROW -- 
measured at ~19x
+ * slower than a regular shuffle on a 20M-row repartition. Batching divides 
the lock traffic
+ * by `batchSize`, the same lesson as any object-batch transport. A batch
+ * array is handed off to the consumer and never touched again by the writer 
(a fresh array
+ * is allocated after each put), so ownership transfer is clean across threads.
+ */
+private[spark] class ChannelShuffleWriter[K, V](
+    handle: BaseShuffleHandle[K, V, _],
+    mapId: Long,
+    batchSize: Int,
+    writeMetrics: ShuffleWriteMetricsReporter)
+  extends ShuffleWriter[K, V] {
+
+  require(batchSize > 0, s"batchSize must be positive, got $batchSize")
+
+  private val dep = handle.dependency
+  private val partitioner = dep.partitioner
+  private val numPartitions = partitioner.numPartitions
+  private val shuffleId = handle.shuffleId
+
+  // The reduce partitions this job actually reads, from the producer stage's 
task property
+  // (set by the DAGScheduler from the result stage's partitions). A record 
routed to a
+  // partition NOT in this set has no consumer -- putting it would fill that 
partition's
+  // bounded queue and, because the writer interleaves all partitions on one 
thread, block
+  // the writer before it can feed even the read partitions or emit their 
end-of-stream,
+  // deadlocking the job. So such records are dropped. Absent property (None) 
means every
+  // partition is live (the normal full-read case: collect, count, a 
full-partition job) and
+  // nothing is dropped.
+  private val liveReducePartitions: Option[Set[Int]] =
+    Option(TaskContext.get())
+      .flatMap(tc =>
+        
Option(tc.getLocalProperty(SparkContext.SPARK_PIPELINED_LIVE_REDUCE_PARTITIONS)))
+      .map(_.split(",").filter(_.nonEmpty).map(_.toInt).toSet)
+
+  // Per-partition liveness, precomputed ONCE from the (static) live set: true 
iff a consumer
+  // reads this reduce partition at all. This is the hot-path gate -- checked 
per input record --
+  // so it is a plain Array[Boolean] load, not a boxed Set lookup: on a large 
repartition the
+  // per-record path must not allocate (the transport's whole point is 
amortizing per-row cost).
+  // Absent property means every partition is live. The OTHER half of "worth 
writing" --
+  // abandonment, which happens at runtime when a reader departs early (e.g. 
LIMIT) -- is dynamic
+  // and is checked where it matters (at hand-off, in putUnlessAbandoned), NOT 
per record:
+  // accumulating a few more rows into an in-memory batch for a 
since-abandoned partition is
+  // harmless because that batch is never put (putUnlessAbandoned drops it).
+  private val liveMask: Array[Boolean] = {
+    val mask = Array.fill(numPartitions)(true)
+    liveReducePartitions.foreach { live =>
+      var p = 0
+      while (p < numPartitions) { mask(p) = live.contains(p); p += 1 }
+    }
+    mask
+  }
+
+  // Hand a batch to a partition's queue, but do NOT block forever if its 
reader departs:
+  // poll with a short timeout and bail out the moment the partition becomes 
abandoned. This
+  // is the cooperative unblock for the early-stop case -- abandon() also 
drains the queue to
+  // release a parked put, and this re-check ensures the writer then stops 
rather than
+  // re-filling. Returns false if the partition was abandoned before the batch 
was accepted.
+  // On a successful hand-off, records the batch's records and the time spent 
(including any
+  // backpressure wait) against the write metrics; a dropped/abandoned batch 
counts nothing,
+  // since those records are never shuffled out. `records` is the number of 
pairs in `batch`
+  // (a full batch is `batchSize`, a trimmed tail is shorter; the 
end-of-stream marker is 0).
+  private def putUnlessAbandoned(pid: Int, batch: AnyRef, records: Int): 
Boolean = {
+    val q = ChannelShuffleRendezvous.queue(shuffleId, pid)
+    val start = System.nanoTime()
+    while (!ChannelShuffleRendezvous.isAbandoned(shuffleId, pid)) {
+      if (q.offer(batch, 100, java.util.concurrent.TimeUnit.MILLISECONDS)) {
+        // A successful offer can race abandon(): abandon does `add(mark)` 
then `q.clear()`, so if
+        // it ran between the isAbandoned check above and this offer, our 
batch lands AFTER the
+        // clear and would be stranded in the queue (no reader will ever drain 
it). Re-check and
+        // clear it ourselves so nothing is left behind. The reader has 
departed, so discarding is
+        // correct; and it keeps the queue empty for removeShuffle rather than 
pinning a batch.
+        if (ChannelShuffleRendezvous.isAbandoned(shuffleId, pid)) {
+          q.clear()
+          return false
+        }
+        if (records > 0) {
+          writeMetrics.incRecordsWritten(records.toLong)
+          writeMetrics.incWriteTime(System.nanoTime() - start)
+        }
+        return true
+      }
+    }
+    false
+  }
+
+  override def write(records: Iterator[Product2[K, V]]): Unit = {
+    // Abandoned marks left by an EARLIER run of this shuffleId (a 
RangePartitioner sampling job
+    // then the main job; executeTake batches) are reset by the DAGScheduler 
when it submits this
+    // producer stage -- before any map task of this run starts (see
+    // ChannelShuffleRendezvous.clearAbandonedForShuffle). The writer must NOT 
clear them itself:
+    // map tasks of the same run are concurrent, and a late one clearing a 
mark would erase a
+    // departure a sibling's reader had already recorded for this run, 
re-hanging the writer.
+
+    // One in-progress batch per reduce partition, plus its fill count.
+    val batches = Array.fill(numPartitions)(new Array[AnyRef](batchSize))
+    val sizes = new Array[Int](numPartitions)
+
+    while (records.hasNext) {
+      val rec = records.next()
+      val pid = partitioner.getPartition(rec._1)
+      // Only accumulate for partitions a consumer reads (liveMask). 
Abandonment is not checked
+      // here -- it is handled at hand-off in putUnlessAbandoned (see 
liveMask's comment).
+      if (liveMask(pid)) {
+        // Records must already be detached from the producer's reused row 
buffers by the time
+        // they reach here (the producer reuses its output UnsafeRow across 
iterations, and the
+        // consumer reads on another thread). The copy is done in the SQL 
layer's
+        // ShuffleWriteProcessor for the pipelined path -- where 
InternalRow.copy() is available
+        // -- rather than here, because this class lives in `core` and cannot 
reference SQL rows,
+        // and the UnsafeRow serializer offers no single-object copy. So batch 
the pair as-is.
+        batches(pid)(sizes(pid)) = (rec._1, rec._2)
+        sizes(pid) += 1
+        if (sizes(pid) == batchSize) {
+          putUnlessAbandoned(pid, batches(pid), batchSize)
+          batches(pid) = new Array[AnyRef](batchSize)
+          sizes(pid) = 0
+        }
+      }
+    }
+
+    // Flush partial batches (trimmed so the reader can iterate array length 
directly), then
+    // signal end-of-stream to every partition still wanted, so each live 
reader can count
+    // this map task as done. Same thread, same queue: data always precedes 
the marker. A
+    // partition that is dead (no reader) or abandoned (reader departed) gets 
neither -- its
+    // queue is left for removeShuffle to drop.
+    var p = 0
+    while (p < numPartitions) {
+      if (liveMask(p)) {
+        if (sizes(p) > 0) {
+          putUnlessAbandoned(p, Arrays.copyOf(batches(p), sizes(p)), sizes(p))
+        }
+        // Re-check: the reader may have departed while the trimmed batch was 
being put.
+        if (!ChannelShuffleRendezvous.isAbandoned(shuffleId, p)) {
+          putUnlessAbandoned(p, ChannelShuffleRendezvous.EndOfStream, records 
= 0)
+        }
+      }
+      p += 1
+    }
+  }
+
+  override def stop(success: Boolean): Option[MapStatus] = {
+    // A pipelined reducer never reads partition lengths, but the 
ShuffleWriter contract
+    // still requires a MapStatus. Return an all-zero placeholder, mirroring 
the RPC
+    // streaming writer.
+    Some(MapStatus(
+      SparkEnv.get.blockManager.shuffleServerId,
+      Array.fill(numPartitions)(0L),
+      mapId))
+  }
+
+  override def getPartitionLengths(): Array[Long] = 
Array.fill(numPartitions)(0L)
+}
+
+/**
+ * Reduce-side of the in-process pipelined shuffle. Drains the shared queue 
for this reduce
+ * partition batch by batch, handing rows to the consumer stage as the map 
tasks produce
+ * them, until every map task has signalled end-of-stream.
+ *
+ * `numMaps` is the number of map tasks feeding this shuffle; the reader stops 
after it has
+ * observed that many [[ChannelShuffleRendezvous.EndOfStream]] markers on its 
queue.
+ *
+ * ONE reduce partition per reader task ONLY: `endPartition - startPartition` 
must be 1. The
+ * channel transport cannot serve a coalesced multi-partition range. The 
reader would have to
+ * drain the range's queues in some order, but the map-side writer interleaves 
all partitions
+ * on ONE thread and blocks on a full bounded queue; if the reader drains 
partition `start` to
+ * completion before touching `start+1` while the writer has parked filling 
`start+1`, the two
+ * deadlock with no timeout escape. Spark never sends a coalesced spec here 
today -- AQE keeps a
+ * pipelined exchange out of any ShuffleQueryStage, so 
CoalesceShufflePartitions never coalesces
+ * it, and both the AQE and non-AQE readers use width-1 
CoalescedPartitionSpec(i, i+1). The
+ * `require` below makes that a hard, fail-loud invariant rather than a silent 
hang if a future
+ * change ever lets a coalesced spec reach a pipelined dependency.
+ */
+private[spark] class ChannelShuffleReader[K, C](
+    handle: BaseShuffleHandle[K, _, C],
+    startPartition: Int,
+    endPartition: Int,
+    numMaps: Int,
+    readMetrics: ShuffleReadMetricsReporter)
+  extends ShuffleReader[K, C] {
+
+  require(endPartition - startPartition == 1,
+    s"ChannelShuffleReader supports exactly one reduce partition per task, got 
" +
+      s"[$startPartition, $endPartition); the in-process channel transport 
does not support " +
+      "coalesced multi-partition reads (see class doc).")
+
+  // On task completion (normal end, early stop like LIMIT, or failure) mark 
this reader's
+  // partition as abandoned, so a writer still feeding it stops and does not 
wedge on its bounded
+  // queue. Registered once here; fires whether or not the iterator was 
drained to the end.
+  Option(TaskContext.get()).foreach { tc =>
+    tc.addTaskCompletionListener[Unit] { _ =>
+      var p = startPartition
+      while (p < endPartition) {
+        ChannelShuffleRendezvous.abandon(handle.shuffleId, p)
+        p += 1
+      }
+    }
+  }
+
+  override def read(): Iterator[Product2[K, C]] =
+    (startPartition until endPartition).iterator.flatMap(drainQueue)
+
+  private def drainQueue(reducePartitionId: Int): Iterator[Product2[K, C]] = {
+    val q = ChannelShuffleRendezvous.queue(handle.shuffleId, reducePartitionId)
+    new Iterator[Product2[K, C]] {
+      // The current batch being handed out, and the cursor into it. A null 
batch after
+      // advance() means every map task has signalled end-of-stream: iteration 
is over.
+      private var batch: Array[AnyRef] = _
+      private var pos = 0
+      private var endOfStreamSeen = 0
+      advance()
+
+      // Blocking-drain until the next non-empty data batch, or until every 
map task has
+      // signalled end-of-stream for this queue (then leave `batch` null to 
end iteration).
+      private def advance(): Unit = {
+        batch = null
+        pos = 0
+        // A producer with zero map tasks (numMaps == 0, e.g. a pipelined 
shuffle over an empty
+        // RDD) enqueues nothing and no end-of-stream marker ever arrives; 
without this guard the
+        // take() below would block forever. Terminate immediately with an 
empty iterator. The
+        // check is also correct for numMaps > 0 once every marker has been 
seen (advance is not
+        // called again after batch stays null, but this keeps the invariant 
explicit).
+        if (endOfStreamSeen >= numMaps) return
+        var item = q.take()

Review Comment:
   `take()` here is uninterruptible, so a reader whose producer dies never 
wakes up.
   
   If a map task fails mid-`write()`, it emits no `EndOfStream` for the 
partitions it had not flushed yet. The reduce task for such a partition parks 
in this `take()`. The DAGScheduler aborts the group and kills the reduce task, 
but `killAllTaskAttempts` passes `shouldInterruptTaskThread(job)`, which reads 
`spark.job.interruptOnCancel` and defaults to `false`. The thread is never 
interrupted, so it stays parked here.
   
   The job itself does fail and the user sees the error, so this is not a 
silent hang — but the reduce task thread keeps its executor slot for the 
lifetime of the application. In `local[N]` that permanently costs one core; a 
few producer failures in a session exhaust the pool and every later pipelined 
query then fails gang admission.
   
   The writer already has the cooperative escape for exactly this class of 
problem — `putUnlessAbandoned` polls with a 100ms timeout and re-checks a flag 
instead of blocking forever. The reader needs the symmetric treatment, e.g.
   
   ```scala
   var item: AnyRef = null
   while (item == null) {
     Option(TaskContext.get()).foreach(_.killTaskIfInterrupted())
     item = q.poll(100, TimeUnit.MILLISECONDS)
   }
   ```
   
   `killTaskIfInterrupted` works without thread interruption, since the 
executor sets the interrupted flag on `TaskContext` on kill either way.
   
   Worth a regression test where a producer map task throws: 
`PipelinedLimitHangSuite` covers the early-stop (normal) path but nothing 
covers producer failure.



##########
core/src/main/scala/org/apache/spark/shuffle/local/pipelined/ChannelShuffleRendezvous.scala:
##########
@@ -0,0 +1,142 @@
+/*
+ * 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}
+
+/**
+ * Process-wide rendezvous between the map (writer) and reduce (reader) sides 
of an
+ * in-process pipelined shuffle. One bounded queue exists per
+ * `(shuffleId, 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.
+ *
+ * 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
+
+  // Keyed by (shuffleId, reducePartitionId). Values are AnyRef because the 
queue carries
+  // both record batches (Array[AnyRef]) and the EndOfStream marker.
+  private val queues =
+    new ConcurrentHashMap[(Int, Int), LinkedBlockingQueue[AnyRef]]()
+
+  // (shuffleId, reducePartitionId) keys 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 =
+    java.util.concurrent.ConcurrentHashMap.newKeySet[(Int, Int)]()
+
+  /**
+   * Default per-queue capacity in BATCHES (not rows): with the default 
1024-row batch this
+   * bounds the in-flight hand-off at ~64K rows per reduce partition.
+   */
+  private val DefaultCapacity = 64

Review Comment:
   `batchSize` is a registered conf but the queue depth that multiplies it is a 
hard-coded constant, and the two together decide how much heap the transport 
pins.
   
   Worst-case in-flight residency for one shuffle is `numPartitions * 64 * 
batchSize` rows, held as strong references in this `object` and invisible to 
the memory manager (no `MemoryConsumer`, no spill). Gang admission bounds 
`numPartitions` in practice, so this is not the multi-million-row figure the 
raw arithmetic suggests, but it is still tens of MB of live rows that a user 
tuning `spark.shuffle.pipelined.channel.batchSize` upward has no way to reason 
about — the doc string mentions "per-partition buffering" without saying it is 
multiplied by 64.
   
   Either promote the capacity to a conf next to `batchSize`, or state the `64 
* batchSize * numPartitions` product in that conf's doc.



##########
core/src/main/scala/org/apache/spark/shuffle/local/pipelined/ChannelShuffleWriterReader.scala:
##########
@@ -0,0 +1,280 @@
+/*
+ * 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.Arrays
+
+import org.apache.spark.{SparkContext, SparkEnv, TaskContext}
+import org.apache.spark.scheduler.MapStatus
+import org.apache.spark.shuffle.{BaseShuffleHandle, ShuffleReader, 
ShuffleReadMetricsReporter, ShuffleWriteMetricsReporter, ShuffleWriter}
+
+/**
+ * Map-side of the in-process pipelined shuffle. Each input record is routed 
to the reduce
+ * partition its key hashes to and accumulated in a per-partition batch; a 
FULL batch (an
+ * `Array[AnyRef]` of `batchSize` pairs) is pushed onto that partition's 
shared queue in one
+ * queue operation (see [[ChannelShuffleRendezvous]]) -- the consumer stage, 
running
+ * concurrently, drains it batch by batch. No serialization, no disk, no 
network.
+ *
+ * Batching is what makes the transport viable for large unaggregated 
shuffles: the queue
+ * costs a lock acquisition per operation (~hundreds of ns under 
producer/consumer
+ * contention), so handing rows across one at a time costs that PER ROW -- 
measured at ~19x
+ * slower than a regular shuffle on a 20M-row repartition. Batching divides 
the lock traffic
+ * by `batchSize`, the same lesson as any object-batch transport. A batch
+ * array is handed off to the consumer and never touched again by the writer 
(a fresh array
+ * is allocated after each put), so ownership transfer is clean across threads.
+ */
+private[spark] class ChannelShuffleWriter[K, V](
+    handle: BaseShuffleHandle[K, V, _],
+    mapId: Long,
+    batchSize: Int,
+    writeMetrics: ShuffleWriteMetricsReporter)
+  extends ShuffleWriter[K, V] {
+
+  require(batchSize > 0, s"batchSize must be positive, got $batchSize")
+
+  private val dep = handle.dependency
+  private val partitioner = dep.partitioner
+  private val numPartitions = partitioner.numPartitions
+  private val shuffleId = handle.shuffleId
+
+  // The reduce partitions this job actually reads, from the producer stage's 
task property
+  // (set by the DAGScheduler from the result stage's partitions). A record 
routed to a
+  // partition NOT in this set has no consumer -- putting it would fill that 
partition's
+  // bounded queue and, because the writer interleaves all partitions on one 
thread, block
+  // the writer before it can feed even the read partitions or emit their 
end-of-stream,
+  // deadlocking the job. So such records are dropped. Absent property (None) 
means every
+  // partition is live (the normal full-read case: collect, count, a 
full-partition job) and
+  // nothing is dropped.
+  private val liveReducePartitions: Option[Set[Int]] =
+    Option(TaskContext.get())
+      .flatMap(tc =>
+        
Option(tc.getLocalProperty(SparkContext.SPARK_PIPELINED_LIVE_REDUCE_PARTITIONS)))
+      .map(_.split(",").filter(_.nonEmpty).map(_.toInt).toSet)
+
+  // Per-partition liveness, precomputed ONCE from the (static) live set: true 
iff a consumer
+  // reads this reduce partition at all. This is the hot-path gate -- checked 
per input record --
+  // so it is a plain Array[Boolean] load, not a boxed Set lookup: on a large 
repartition the
+  // per-record path must not allocate (the transport's whole point is 
amortizing per-row cost).
+  // Absent property means every partition is live. The OTHER half of "worth 
writing" --
+  // abandonment, which happens at runtime when a reader departs early (e.g. 
LIMIT) -- is dynamic
+  // and is checked where it matters (at hand-off, in putUnlessAbandoned), NOT 
per record:
+  // accumulating a few more rows into an in-memory batch for a 
since-abandoned partition is
+  // harmless because that batch is never put (putUnlessAbandoned drops it).
+  private val liveMask: Array[Boolean] = {
+    val mask = Array.fill(numPartitions)(true)
+    liveReducePartitions.foreach { live =>
+      var p = 0
+      while (p < numPartitions) { mask(p) = live.contains(p); p += 1 }
+    }
+    mask
+  }
+
+  // Hand a batch to a partition's queue, but do NOT block forever if its 
reader departs:
+  // poll with a short timeout and bail out the moment the partition becomes 
abandoned. This
+  // is the cooperative unblock for the early-stop case -- abandon() also 
drains the queue to
+  // release a parked put, and this re-check ensures the writer then stops 
rather than
+  // re-filling. Returns false if the partition was abandoned before the batch 
was accepted.
+  // On a successful hand-off, records the batch's records and the time spent 
(including any
+  // backpressure wait) against the write metrics; a dropped/abandoned batch 
counts nothing,
+  // since those records are never shuffled out. `records` is the number of 
pairs in `batch`
+  // (a full batch is `batchSize`, a trimmed tail is shorter; the 
end-of-stream marker is 0).
+  private def putUnlessAbandoned(pid: Int, batch: AnyRef, records: Int): 
Boolean = {
+    val q = ChannelShuffleRendezvous.queue(shuffleId, pid)
+    val start = System.nanoTime()
+    while (!ChannelShuffleRendezvous.isAbandoned(shuffleId, pid)) {
+      if (q.offer(batch, 100, java.util.concurrent.TimeUnit.MILLISECONDS)) {
+        // A successful offer can race abandon(): abandon does `add(mark)` 
then `q.clear()`, so if
+        // it ran between the isAbandoned check above and this offer, our 
batch lands AFTER the
+        // clear and would be stranded in the queue (no reader will ever drain 
it). Re-check and
+        // clear it ourselves so nothing is left behind. The reader has 
departed, so discarding is
+        // correct; and it keeps the queue empty for removeShuffle rather than 
pinning a batch.
+        if (ChannelShuffleRendezvous.isAbandoned(shuffleId, pid)) {
+          q.clear()
+          return false
+        }
+        if (records > 0) {
+          writeMetrics.incRecordsWritten(records.toLong)
+          writeMetrics.incWriteTime(System.nanoTime() - start)
+        }
+        return true
+      }
+    }
+    false
+  }
+
+  override def write(records: Iterator[Product2[K, V]]): Unit = {
+    // Abandoned marks left by an EARLIER run of this shuffleId (a 
RangePartitioner sampling job
+    // then the main job; executeTake batches) are reset by the DAGScheduler 
when it submits this
+    // producer stage -- before any map task of this run starts (see
+    // ChannelShuffleRendezvous.clearAbandonedForShuffle). The writer must NOT 
clear them itself:
+    // map tasks of the same run are concurrent, and a late one clearing a 
mark would erase a
+    // departure a sibling's reader had already recorded for this run, 
re-hanging the writer.
+
+    // One in-progress batch per reduce partition, plus its fill count.
+    val batches = Array.fill(numPartitions)(new Array[AnyRef](batchSize))
+    val sizes = new Array[Int](numPartitions)
+
+    while (records.hasNext) {
+      val rec = records.next()
+      val pid = partitioner.getPartition(rec._1)
+      // Only accumulate for partitions a consumer reads (liveMask). 
Abandonment is not checked
+      // here -- it is handled at hand-off in putUnlessAbandoned (see 
liveMask's comment).
+      if (liveMask(pid)) {
+        // Records must already be detached from the producer's reused row 
buffers by the time
+        // they reach here (the producer reuses its output UnsafeRow across 
iterations, and the
+        // consumer reads on another thread). The copy is done in the SQL 
layer's
+        // ShuffleWriteProcessor for the pipelined path -- where 
InternalRow.copy() is available
+        // -- rather than here, because this class lives in `core` and cannot 
reference SQL rows,
+        // and the UnsafeRow serializer offers no single-object copy. So batch 
the pair as-is.
+        batches(pid)(sizes(pid)) = (rec._1, rec._2)
+        sizes(pid) += 1
+        if (sizes(pid) == batchSize) {
+          putUnlessAbandoned(pid, batches(pid), batchSize)
+          batches(pid) = new Array[AnyRef](batchSize)
+          sizes(pid) = 0
+        }
+      }
+    }
+
+    // Flush partial batches (trimmed so the reader can iterate array length 
directly), then
+    // signal end-of-stream to every partition still wanted, so each live 
reader can count
+    // this map task as done. Same thread, same queue: data always precedes 
the marker. A
+    // partition that is dead (no reader) or abandoned (reader departed) gets 
neither -- its
+    // queue is left for removeShuffle to drop.
+    var p = 0
+    while (p < numPartitions) {
+      if (liveMask(p)) {
+        if (sizes(p) > 0) {
+          putUnlessAbandoned(p, Arrays.copyOf(batches(p), sizes(p)), sizes(p))
+        }
+        // Re-check: the reader may have departed while the trimmed batch was 
being put.
+        if (!ChannelShuffleRendezvous.isAbandoned(shuffleId, p)) {
+          putUnlessAbandoned(p, ChannelShuffleRendezvous.EndOfStream, records 
= 0)
+        }
+      }
+      p += 1
+    }
+  }
+
+  override def stop(success: Boolean): Option[MapStatus] = {
+    // A pipelined reducer never reads partition lengths, but the 
ShuffleWriter contract
+    // still requires a MapStatus. Return an all-zero placeholder, mirroring 
the RPC
+    // streaming writer.
+    Some(MapStatus(
+      SparkEnv.get.blockManager.shuffleServerId,
+      Array.fill(numPartitions)(0L),
+      mapId))
+  }
+
+  override def getPartitionLengths(): Array[Long] = 
Array.fill(numPartitions)(0L)
+}
+
+/**
+ * Reduce-side of the in-process pipelined shuffle. Drains the shared queue 
for this reduce
+ * partition batch by batch, handing rows to the consumer stage as the map 
tasks produce
+ * them, until every map task has signalled end-of-stream.
+ *
+ * `numMaps` is the number of map tasks feeding this shuffle; the reader stops 
after it has
+ * observed that many [[ChannelShuffleRendezvous.EndOfStream]] markers on its 
queue.
+ *
+ * ONE reduce partition per reader task ONLY: `endPartition - startPartition` 
must be 1. The
+ * channel transport cannot serve a coalesced multi-partition range. The 
reader would have to
+ * drain the range's queues in some order, but the map-side writer interleaves 
all partitions
+ * on ONE thread and blocks on a full bounded queue; if the reader drains 
partition `start` to
+ * completion before touching `start+1` while the writer has parked filling 
`start+1`, the two
+ * deadlock with no timeout escape. Spark never sends a coalesced spec here 
today -- AQE keeps a
+ * pipelined exchange out of any ShuffleQueryStage, so 
CoalesceShufflePartitions never coalesces
+ * it, and both the AQE and non-AQE readers use width-1 
CoalescedPartitionSpec(i, i+1). The
+ * `require` below makes that a hard, fail-loud invariant rather than a silent 
hang if a future
+ * change ever lets a coalesced spec reach a pipelined dependency.
+ */
+private[spark] class ChannelShuffleReader[K, C](
+    handle: BaseShuffleHandle[K, _, C],
+    startPartition: Int,
+    endPartition: Int,
+    numMaps: Int,
+    readMetrics: ShuffleReadMetricsReporter)
+  extends ShuffleReader[K, C] {
+
+  require(endPartition - startPartition == 1,
+    s"ChannelShuffleReader supports exactly one reduce partition per task, got 
" +
+      s"[$startPartition, $endPartition); the in-process channel transport 
does not support " +
+      "coalesced multi-partition reads (see class doc).")
+
+  // On task completion (normal end, early stop like LIMIT, or failure) mark 
this reader's
+  // partition as abandoned, so a writer still feeding it stops and does not 
wedge on its bounded
+  // queue. Registered once here; fires whether or not the iterator was 
drained to the end.
+  Option(TaskContext.get()).foreach { tc =>
+    tc.addTaskCompletionListener[Unit] { _ =>
+      var p = startPartition
+      while (p < endPartition) {
+        ChannelShuffleRendezvous.abandon(handle.shuffleId, p)
+        p += 1
+      }
+    }
+  }
+
+  override def read(): Iterator[Product2[K, C]] =
+    (startPartition until endPartition).iterator.flatMap(drainQueue)
+
+  private def drainQueue(reducePartitionId: Int): Iterator[Product2[K, C]] = {
+    val q = ChannelShuffleRendezvous.queue(handle.shuffleId, reducePartitionId)
+    new Iterator[Product2[K, C]] {
+      // The current batch being handed out, and the cursor into it. A null 
batch after
+      // advance() means every map task has signalled end-of-stream: iteration 
is over.
+      private var batch: Array[AnyRef] = _
+      private var pos = 0
+      private var endOfStreamSeen = 0
+      advance()
+
+      // Blocking-drain until the next non-empty data batch, or until every 
map task has
+      // signalled end-of-stream for this queue (then leave `batch` null to 
end iteration).
+      private def advance(): Unit = {
+        batch = null
+        pos = 0
+        // A producer with zero map tasks (numMaps == 0, e.g. a pipelined 
shuffle over an empty
+        // RDD) enqueues nothing and no end-of-stream marker ever arrives; 
without this guard the
+        // take() below would block forever. Terminate immediately with an 
empty iterator. The
+        // check is also correct for numMaps > 0 once every marker has been 
seen (advance is not
+        // called again after batch stays null, but this keeps the invariant 
explicit).
+        if (endOfStreamSeen >= numMaps) return
+        var item = q.take()
+        while (item eq ChannelShuffleRendezvous.EndOfStream) {
+          endOfStreamSeen += 1
+          if (endOfStreamSeen >= numMaps) return
+          item = q.take()
+        }
+        batch = item.asInstanceOf[Array[AnyRef]]
+        // Count the records handed to the consumer as this batch is fetched. 
Local, so this
+        // is the read-side records metric; there is no remote fetch and no 
wire bytes.
+        readMetrics.incRecordsRead(batch.length.toLong)

Review Comment:
   Only `incRecordsRead` is reported on the read side — there is no 
`incFetchWaitTime`, so the reader's blocking wait on the channel never shows up.
   
   For this transport that wait is the interesting read-side number: it is the 
backpressure signal that tells a user whether the consumer or the producer is 
the bottleneck, which is the main thing a batch-size or queue-depth tuning 
decision turns on. The class doc for the transport and the PR description both 
say "records + read/write time are reported to the shuffle metrics", which 
currently holds for the writer (`incWriteTime` in `putUnlessAbandoned`) but not 
for the reader.



##########
sql/core/src/test/scala/org/apache/spark/sql/execution/exchange/PipelinedShuffleBenchmark.scala:
##########
@@ -0,0 +1,452 @@
+/*
+ * 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.benchmark.Benchmark
+import org.apache.spark.sql.SparkSession
+import org.apache.spark.sql.execution.benchmark.SqlBasedBenchmark
+
+/**
+ * Benchmark to compare the in-process pipelined channel shuffle against the 
regular
+ * (materializing) shuffle on simple batch queries (SPARK-57399).
+ *
+ * Fair-comparison constraints, read before trusting any number:
+ *   - Runs on `local[N]` with N = physical cores, and only queries whose 
pipelined
+ *     whole-group slot demand is <= N, so the concurrently-scheduled 
pipelined stages do NOT
+ *     oversubscribe the cores. A demand > cores run would measure thread 
thrash, not the
+ *     transport, and is intentionally avoided here.
+ *   - Pipelined overlaps map+reduce stages (uses more concurrent slots) vs 
the baseline's
+ *     sequential map-then-reduce; with demand <= cores neither is 
slot-limited, so the delta
+ *     reflects stage overlap minus channel overhead, which is the honest 
comparison.
+ *
+ * To run this benchmark:
+ * {{{
+ *   1. build/sbt "sql/Test/runMain
+ *      org.apache.spark.sql.execution.exchange.PipelinedShuffleBenchmark"
+ *   2. generate result: SPARK_GENERATE_BENCHMARK_FILES=1 build/sbt
+ *      "sql/Test/runMain
+ *      org.apache.spark.sql.execution.exchange.PipelinedShuffleBenchmark"
+ *      Results will be written to 
"benchmarks/PipelinedShuffleBenchmark-results.txt".
+ * }}}
+ */
+object PipelinedShuffleBenchmark extends SqlBasedBenchmark {
+
+  override def getSparkSession: SparkSession = {
+    SparkSession.builder()
+      .master("local[1]")
+      .appName(this.getClass.getCanonicalName)
+      .config("spark.ui.enabled", "false")
+      .getOrCreate()
+  }
+
+  private val cores = Runtime.getRuntime.availableProcessors()
+  private val numRows = 20000000L  // 20M: large enough that transport cost 
dominates startup
+  private val inputParts = 6       // demand = 6 (input) + 8 (shuffle) = 14 <= 
16 cores

Review Comment:
   `inputParts` is pinned to a 16-core assumption while `cores` right above it 
is read from the machine, so the benchmark is not portable.
   
   On anything below 14 cores the pipelined configurations do not produce 
slower numbers — they fail gang admission with 
`CONCURRENT_SCHEDULER_INSUFFICIENT_SLOT` and the run dies. Since the whole 
point of the fair-comparison preamble is that demand stays under the core 
count, deriving `inputParts` from `cores` (and skipping, with a clear message, 
when even the minimum shape does not fit) would make the checked-in results 
reproducible outside the machine they were generated on.



##########
sql/core/src/main/scala/org/apache/spark/sql/execution/exchange/ShuffleExchangeExec.scala:
##########
@@ -570,7 +570,12 @@ 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.
+          shuffleWriterProcessor = createShuffleWriteProcessor(
+            writeMetrics,
+            copyRows = 
SparkEnv.get.pipelinedShuffleManager.requiresDetachedRecords),

Review Comment:
   Two small redundancies on the pipelined write path:
   
   1. When `needToCopyObjectsBeforeShuffle(part)` is true, 
`prepareShuffleDependency` already emits `(pid, row.copy())`, and this then 
copies every row a second time. `copyRows = requiresDetachedRecords && 
!needToCopyObjectsBeforeShuffle(part)` avoids that.
   
   2. The write processor builds a fresh `Tuple2` per record, and 
`ChannelShuffleWriter.write` immediately builds another one 
(`batches(pid)(sizes(pid)) = (rec._1, rec._2)`). Given `requiresDetachedRecords 
= true` is the contract that the pair reaching the writer is already detached, 
the re-wrap in the writer is redundant — storing `rec` directly removes one 
allocation per record from the hot loop the batching design exists to protect.



##########
core/src/main/scala/org/apache/spark/scheduler/DAGScheduler.scala:
##########
@@ -2604,6 +2687,60 @@ private[spark] class DAGScheduler(
       .getOrElse(new Properties())
     addPySparkConfigsToProperties(stage, properties)
 
+    // For a pipelined PRODUCER stage, tell its tasks which reduce partitions 
the job actually
+    // reads (the result stage's partitions). 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. Only the map-side producer needs this; a regular full-read job's 
result stage
+    // covers every partition, so the property (if written) lists them all and 
drops nothing.
+    // The live set is per-SHUFFLE-EDGE, not per-job: it is the reduce 
partitions the
+    // consumer of THIS shuffle reads. It equals the job's result partitions 
ONLY for the
+    // producer whose shuffle the result stage reads DIRECTLY (result 
partition i maps to
+    // that producer's reduce partition i). 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, so it must stay fully live -- setting the 
result's subset
+    // there would make it drop partitions the downstream stage still needs, 
deadlocking.
+    // So set the property only on the result-feeding producer.
+    stage match {
+      case sms: ShuffleMapStage if isPipelinedProducer(stage) =>
+        // Notify the pipelined manager that this producer stage is being 
(re)submitted, before
+        // any of its map tasks start. A transport with per-run state keyed by 
shuffleId resets it
+        // here -- the one point with no live task of the new run, so the 
reset cannot race the
+        // run's own writers/readers. The in-process channel transport clears 
a prior run's
+        // abandoned-partition marks; the RPC streaming manager keeps no such 
state (no-op default).
+        // The scheduler stays transport-agnostic: it calls the 
PipelinedShuffleManager trait, not
+        // a concrete transport.
+        SparkEnv.get.pipelinedShuffleManager
+          .onPipelinedProducerStageSubmit(sms.shuffleDep.shuffleId)
+        jobIdToActiveJob.get(jobId).map(_.finalStage).collect { case rs: 
ResultStage => rs }
+          .foreach { rs =>
+            // The live set is the result stage's partition ids. Those are the 
shuffle's reduce
+            // partition ids -- so partition i means reduce partition i -- 
ONLY when the result RDD
+            // reaches this shuffle through an IDENTITY-PRESERVING chain: 
every hop a 1:1,
+            // same-index OneToOneDependency (e.g. the mapPartitions wrapper 
executeTake/collect
+            // adds over the ShuffledRowRDD, which preserves partition count 
and index). It is NOT
+            // enough for the shuffle to be merely reachable: a narrow 
operator that REMAPS
+            // partitions (coalesce -> a custom NarrowDependency, union -> a 
RangeDependency offset)
+            // makes result partition ids differ from reduce partition ids, 
and treating them as
+            // the live set would drop partitions a downstream operator still 
pulls -- hanging the
+            // reader. When the chain is not identity-preserving the property 
is left unset (all
+            // partitions live) and that operator drains every reduce 
partition.
+            def readsShuffleByIdentity(rdd: RDD[_]): Boolean = 
rdd.dependencies match {

Review Comment:
   The identity check is the right shape, but RDD-level identity does not by 
itself imply partition-index identity at the one place it matters.
   
   The RDD matched by the first case is `ShuffledRowRDD`, which is precisely 
the RDD that maps its partition `i` to an arbitrary reducer index through 
`partitionSpecs`. With the default constructor the specs are 
`CoalescedPartitionSpec(i, i + 1)` and the identity holds, but that is assumed 
here rather than established.
   
   `ChannelShuffleReader`'s `require(endPartition - startPartition == 1)` 
guards the *width* of a spec, not its *offset*. A `CoalescedPartitionSpec(5, 
6)` sitting at result index 0 passes that `require`, while the live set 
computed here would be `{0}` — the writer drops everything routed to reduce 
partition 5 and emits no end-of-stream for it, and the reader for partition 5 
blocks.
   
   Nothing produces that today (AQE keeps a pipelined exchange out of 
`ShuffleQueryStageExec`, so `getShuffleRDD(specs)` is never reached for one), 
so this is defense-in-depth rather than a live bug. But since the failure mode 
is a hang and the guard is cheap, it would be worth also requiring 
`rs.rdd.partitions.length == sd.partitioner.numPartitions` before writing the 
property, so a future change that opens the spec path degrades to "all 
partitions live" instead of hanging.



##########
core/src/test/scala/org/apache/spark/shuffle/PipelinedShuffleRoutingSuite.scala:
##########
@@ -85,7 +85,13 @@ private class DefaultRecordingManager(conf: SparkConf, 
isDriver: Boolean)
   override def shuffleBlockResolver: ShuffleBlockResolver = 
mock(classOf[ShuffleBlockResolver])
 }
 private class IncrementalRecordingManager(conf: SparkConf, isDriver: Boolean)
-  extends RecordingShuffleManager(conf, isDriver) with PipelinedShuffleManager
+  extends RecordingShuffleManager(conf, isDriver) with PipelinedShuffleManager 
{
+  // A non-streaming pipelined manager (an in-process transport): it needs no
+  // StreamingShuffleOutputTracker, unlike the RPC streaming manager that the 
trait defaults to.
+  // The "SparkEnv does not initialize the tracker when the incremental 
manager is not streaming"
+  // and fail-loud tests rely on this manager reporting false here.
+  override def usesStreamingShuffleOutputTracker: Boolean = false

Review Comment:
   Dropping `"createShuffleMapStage fails loud for a pipelined dependency when 
no streaming tracker"` in this file is a net loss of coverage rather than a 
wash.
   
   The old invariant is genuinely relaxed, so the test as written had to go. 
But the replacement behavior — a pipelined shuffle whose manager reports 
`usesStreamingShuffleOutputTracker = false` registers with no output tracker at 
all and the job still runs — is exactly what this suite exists to pin, and it 
is now asserted nowhere here. The `None` arm added in 
`DAGScheduler.outputTrackerMaster` is also unexercised.
   
   Converting the deleted test into the positive case (this manager configured, 
job succeeds, `streamingShuffleOutputTracker.isEmpty`, and the shuffle is 
absent from `mapOutputTracker`) keeps the coverage where the routing rules are 
tested.



##########
core/src/main/scala/org/apache/spark/shuffle/local/pipelined/PipelinedChannelShuffleManager.scala:
##########
@@ -0,0 +1,133 @@
+/*
+ * 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)
+
+  override def usesStreamingShuffleOutputTracker: Boolean = false
+
+  // Records cross the channel as object references read by a concurrent 
consumer thread; the
+  // SQL layer must detach each row from the producer's reused buffer before 
the writer sees it.
+  override def requiresDetachedRecords: Boolean = true
+
+  // Reset a prior run's abandoned-partition marks when this shuffle's 
producer stage is
+  // (re)submitted -- before any map task of the new run starts, so it cannot 
race the run's own
+  // writers/readers. A shuffleId is re-run within one query (a 
RangePartitioner sample job then
+  // the main job; executeTake's per-batch jobs), and abandonment is a per-run 
fact.
+  override def onPipelinedProducerStageSubmit(shuffleId: Int): Unit =
+    ChannelShuffleRendezvous.clearAbandonedForShuffle(shuffleId)
+
+  override def registerShuffle[K, V, C](
+      shuffleId: Int,
+      dependency: ShuffleDependency[K, V, C]): ShuffleHandle =
+    new ChannelShuffleHandle(shuffleId, dependency, 
dependency.rdd.partitions.length)
+
+  override def getWriter[K, V](
+      handle: ShuffleHandle,
+      mapId: Long,
+      context: TaskContext,
+      metrics: ShuffleWriteMetricsReporter): ShuffleWriter[K, V] =
+    new ChannelShuffleWriter[K, V](
+      handle.asInstanceOf[BaseShuffleHandle[K, V, _]], mapId, batchSize, 
metrics)
+
+  override def getReader[K, C](
+      handle: ShuffleHandle,
+      startMapIndex: Int,
+      endMapIndex: Int,
+      startPartition: Int,
+      endPartition: Int,
+      context: TaskContext,
+      metrics: ShuffleReadMetricsReporter): ShuffleReader[K, C] = {
+    // A reduce task reads the reduce-partition range [startPartition, 
endPartition). Core
+    // ShuffledRDD uses width 1, but SQL's ShuffledRowRDD may coalesce several 
reduce

Review Comment:
   This comment contradicts the reader it describes: it says `ShuffledRowRDD` 
may coalesce several reduce partitions into one reader task "so the reader must 
honor the whole range", while `ChannelShuffleReader` opens with 
`require(endPartition - startPartition == 1)` and its class doc explains at 
length why a coalesced range cannot be served. Worth reconciling so the next 
reader of this file does not trust the wrong one.



##########
core/src/main/scala/org/apache/spark/shuffle/local/pipelined/PipelinedChannelShuffleManager.scala:
##########
@@ -0,0 +1,133 @@
+/*
+ * 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)
+
+  override def usesStreamingShuffleOutputTracker: Boolean = false
+
+  // Records cross the channel as object references read by a concurrent 
consumer thread; the
+  // SQL layer must detach each row from the producer's reused buffer before 
the writer sees it.
+  override def requiresDetachedRecords: Boolean = true
+
+  // Reset a prior run's abandoned-partition marks when this shuffle's 
producer stage is
+  // (re)submitted -- before any map task of the new run starts, so it cannot 
race the run's own
+  // writers/readers. A shuffleId is re-run within one query (a 
RangePartitioner sample job then
+  // the main job; executeTake's per-batch jobs), and abandonment is a per-run 
fact.
+  override def onPipelinedProducerStageSubmit(shuffleId: Int): Unit =
+    ChannelShuffleRendezvous.clearAbandonedForShuffle(shuffleId)
+
+  override def registerShuffle[K, V, C](
+      shuffleId: Int,
+      dependency: ShuffleDependency[K, V, C]): ShuffleHandle =
+    new ChannelShuffleHandle(shuffleId, dependency, 
dependency.rdd.partitions.length)
+
+  override def getWriter[K, V](
+      handle: ShuffleHandle,
+      mapId: Long,
+      context: TaskContext,
+      metrics: ShuffleWriteMetricsReporter): ShuffleWriter[K, V] =
+    new ChannelShuffleWriter[K, V](
+      handle.asInstanceOf[BaseShuffleHandle[K, V, _]], mapId, batchSize, 
metrics)
+
+  override def getReader[K, C](
+      handle: ShuffleHandle,
+      startMapIndex: Int,
+      endMapIndex: Int,
+      startPartition: Int,
+      endPartition: Int,
+      context: TaskContext,
+      metrics: ShuffleReadMetricsReporter): ShuffleReader[K, C] = {
+    // A reduce task reads the reduce-partition range [startPartition, 
endPartition). Core
+    // ShuffledRDD uses width 1, but SQL's ShuffledRowRDD may coalesce several 
reduce
+    // partitions into one reader task, so the reader must honor the whole 
range. Map-index
+    // bounds are irrelevant to the channel transport (all map tasks share 
each partition's
+    // queue). The final 5-arg getReader forwards here with the correct 
partition range.
+    //
+    // numMaps -- how many end-of-stream markers to expect per queue -- was 
stamped into the
+    // handle at registration, never kept in mutable manager state (see class 
scaladoc).
+    val h = handle.asInstanceOf[ChannelShuffleHandle[K, _, C]]
+    new ChannelShuffleReader[K, C](h, startPartition, endPartition, h.numMaps, 
metrics)
+  }
+
+  override def unregisterShuffle(shuffleId: Int): Boolean = {
+    ChannelShuffleRendezvous.removeShuffle(shuffleId)
+    true
+  }
+
+  override def stop(): Unit = {}

Review Comment:
   `stop()` being empty lets `ChannelShuffleRendezvous` outlive the 
`SparkContext` that filled it, and the rendezvous is keyed only by `(shuffleId, 
reducePartitionId)` with no application scoping.
   
   Queues are freed only through the GC-driven `ContextCleaner` -> 
`unregisterShuffle` path, so anything not collected before the context stops 
stays in the `object` for the rest of the JVM. Stop a context and create 
another in the same JVM — a test fork, or a REPL/notebook restarting the 
context — and shuffle ids start over at 0. The new context's reduce task for 
shuffle 0 then drains whatever the previous context left in that queue: extra 
rows, or an `EndOfStream` count that never reaches `numMaps`.
   
   This is not hypothetical for the suites in this PR: 
`PipelinedChannelShuffleSuite` calls `clearForTesting()` per context, but 
`PipelinedShuffleSqlSuite`, `AQEPipelinedShuffleSuite`, and 
`PipelinedLimitHangSuite` each build and stop their own session without 
clearing it.
   
   `SparkEnv.stop()` already calls `_pipelinedShuffleManager.stop()`, so the 
fix is local:
   
   ```scala
   override def stop(): Unit = ChannelShuffleRendezvous.clear()
   ```
   
   (promoting `clearForTesting` to a real method rather than a test-only one).



-- 
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