viirya commented on code in PR #56878:
URL: https://github.com/apache/spark/pull/56878#discussion_r3541900702


##########
core/src/test/scala/org/apache/spark/shuffle/streaming/StreamingShuffleWriterSuite.scala:
##########
@@ -0,0 +1,279 @@
+/*
+ * 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.streaming
+
+import java.util.Properties
+import java.util.zip.CRC32C
+
+import io.netty.buffer.{ByteBuf, Unpooled}
+import io.netty.channel.{Channel, ChannelConfig, ChannelFuture}
+import io.netty.util.concurrent.GenericFutureListener
+import org.mockito.ArgumentMatchers.any
+import org.mockito.Mockito.when
+import org.scalatest.matchers.should.Matchers
+import org.scalatestplus.mockito.MockitoSugar
+
+import org.apache.spark._
+import org.apache.spark.LocalSparkContext.withSpark
+import org.apache.spark.internal.config.{SHUFFLE_MANAGER, 
STREAMING_SHUFFLE_CHECKSUM_ENABLED, STREAMING_SHUFFLE_NETWORK_BUFFER_SIZE}
+import org.apache.spark.memory.{TaskMemoryManager, TestMemoryManager}
+import org.apache.spark.metrics.MetricsSystem
+import org.apache.spark.network.client.TransportClient
+import org.apache.spark.network.shuffle.streaming.{DataMessage, 
StreamingShuffleMessage, TerminationControlMessage}
+import 
org.apache.spark.shuffle.streaming.StreamingShuffleManager.QUERY_ID_PROPERTY_KEY
+import org.apache.spark.util.ErrorNotifier
+
+/**
+ * Writer-side unit tests that do not require a shuffle reader. End-to-end 
writer <-> reader
+ * behavior is covered by `StreamingShuffleSuite` in the tests PR of this 
stack.
+ */
+class StreamingShuffleWriterSuite
+  extends SparkFunSuite
+  with LocalSparkContext
+  with Matchers
+  with MockitoSugar {
+
+  private def newConf(): SparkConf =
+    new SparkConf().set(SHUFFLE_MANAGER, 
classOf[StreamingShuffleManager].getName)
+
+  private def createTaskContext(conf: SparkConf, partitionId: Int): 
TaskContextImpl = {
+    val properties = new Properties()
+    properties.setProperty(QUERY_ID_PROPERTY_KEY, "test-query-id")
+    val taskMemoryManager = new TaskMemoryManager(new TestMemoryManager(conf), 
0)
+    new TaskContextImpl(
+      stageId = 0,
+      stageAttemptNumber = 0,
+      partitionId,
+      taskAttemptId = 0,
+      attemptNumber = 0,
+      numPartitions = 1,
+      taskMemoryManager = taskMemoryManager,
+      localProperties = properties,
+      metricsSystem = mock[MetricsSystem],
+      cpus = 1)
+  }
+
+  test("getWriter returns a StreamingShuffleWriter") {
+    withSpark(new SparkContext("local", "StreamingShuffleWriterSuite", 
newConf())) { sc =>
+      SparkEnv.get.streamingShuffleOutputTracker.get
+        .asInstanceOf[StreamingShuffleOutputTrackerMaster]
+        .registerShuffle(shuffleId = 0, numMaps = 1, numReduces = 1, jobId = 0)
+      val rdd = sc.parallelize(1 to 4).map(x => (x, x))
+      val dep = new ShuffleDependency[Int, Int, Int](rdd, new 
HashPartitioner(1))
+      val handle = new StreamingShuffleHandle(0, dep)
+      val context = createTaskContext(sc.conf, 0)
+      try {
+        val writer = new StreamingShuffleManager()
+          .getWriter[Int, Int](handle, 0, context, null)
+        writer shouldBe a[StreamingShuffleWriter[_, _]]
+      } finally {
+        // Constructing the writer starts a Netty server; the task-completion 
listener
+        // (cleanupResources) tears it down.
+        context.markTaskCompleted(None)
+      }
+    }
+  }
+
+  test("writer rejects a memory budget that overflows the Int range") {
+    // With a large network buffer size, numPartitions * BUFFER_SIZE * 2 
exceeds Int.MaxValue
+    // for even a handful of readers. The writer must reject this up front 
rather than let the
+    // 32-bit product wrap negative and hang on a semaphore that can never 
grant permits. The
+    // guard fires in the constructor before the Netty server is started, so 
nothing to clean up.
+    val conf = newConf().set(STREAMING_SHUFFLE_NETWORK_BUFFER_SIZE, 256 * 1024 
* 1024)
+    withSpark(new SparkContext("local", "StreamingShuffleWriterSuite", conf)) 
{ sc =>
+      SparkEnv.get.streamingShuffleOutputTracker.get
+        .asInstanceOf[StreamingShuffleOutputTrackerMaster]
+        .registerShuffle(shuffleId = 0, numMaps = 1, numReduces = 16, jobId = 
0)
+      val rdd = sc.parallelize(1 to 4).map(x => (x, x))
+      val dep = new ShuffleDependency[Int, Int, Int](rdd, new 
HashPartitioner(16))
+      val handle = new StreamingShuffleHandle(0, dep)
+      val context = createTaskContext(sc.conf, 0)
+      val e = intercept[IllegalArgumentException] {
+        new StreamingShuffleWriter[Int, Int](handle, 0, context)
+      }
+      assert(e.getMessage.contains("memory budget"))
+    }
+  }
+
+  // Builds a single-partition writer against a freshly registered shuffle. 
The caller must run
+  // this inside a withSpark block and must eventually call 
context.markTaskCompleted(None) to
+  // tear down the Netty server the writer starts in its constructor.
+  private def newWriter(
+      sc: SparkContext,
+      context: TaskContext,
+      errorNotifier: ErrorNotifier = new ErrorNotifier()): 
StreamingShuffleWriter[Int, Int] = {
+    SparkEnv.get.streamingShuffleOutputTracker.get
+      .asInstanceOf[StreamingShuffleOutputTrackerMaster]
+      .registerShuffle(shuffleId = 0, numMaps = 1, numReduces = 1, jobId = 0)
+    val rdd = sc.parallelize(1 to 4).map(x => (x, x))
+    val dep = new ShuffleDependency[Int, Int, Int](rdd, new HashPartitioner(1))
+    val handle = new StreamingShuffleHandle(0, dep)
+    new StreamingShuffleWriter[Int, Int](handle, 0, context, errorNotifier = 
errorNotifier)
+  }
+
+  // A mock TransportClient whose send(ByteBuf) invokes `onSend` and then 
completes the write
+  // successfully (its returned ChannelFuture reports isSuccess = true so the 
writer's normal
+  // done()/buffer-recycling path runs). Binding it into a shard's 
futureClients makes the shard
+  // send directly to this mock instead of over the network.
+  private def bindMockClient(
+      writer: StreamingShuffleWriter[Int, Int],
+      shardId: Int)(onSend: ByteBuf => Unit): Unit = {
+    val client = mock[TransportClient]
+    val channel = mock[Channel]
+    val channelConfig = mock[ChannelConfig]
+    when(client.getChannel).thenReturn(channel)
+    when(channel.config).thenReturn(channelConfig)
+    val succeededFuture = mock[ChannelFuture]
+    when(succeededFuture.isSuccess).thenReturn(true)
+    
when(succeededFuture.addListener(any[GenericFutureListener[ChannelFuture]]))
+      .thenAnswer { invocation =>
+        invocation.getArgument[GenericFutureListener[ChannelFuture]](0)
+          .operationComplete(succeededFuture)
+        succeededFuture
+      }
+    when(client.send(any[ByteBuf])).thenAnswer { invocation =>

Review Comment:
   This mock doesn't emulate the ownership contract of 
`TransportClient.send(ByteBuf)`: the real transport releases the buffer once 
the write completes (success or failure), but the mock only copies it and never 
releases. The consequence in the checksum test: the composite — and the 
`retainedSlice` it holds on the raw buffer — is never released, so when 
`done()` runs, `rawBuffer.refCnt()` is 2, not 1, and the test silently drives 
the `refCnt() != 1` internal-error branch instead: `errorNotifier` gets an 
`AssertionError("INTERNAL ERROR: Unexpected refcnt 2")`, the raw buffer isn't 
recycled, and both buffers leak. The checksum assertions still pass (they run 
against the copy), so CI won't notice — but the helper's comment ("the writer's 
normal done()/buffer-recycling path runs") isn't true as written, and the test 
can't catch a regression in the recycling logic.
   
   Fix is small: add `buf.release()` after `onSend(...)` in this answer to 
emulate the transport's ownership transfer, and assert 
`writer.errorNotifier.getError() shouldBe empty` at the end of the checksum 
test so the normal path is actually verified. (Same hygiene nit applies to the 
async-failure test — its composite is never released either; releasing in that 
mock too would match the documented contract that the transport frees the 
buffer on failure as well.)



##########
core/src/main/scala/org/apache/spark/shuffle/streaming/StreamingShuffleWriter.scala:
##########
@@ -0,0 +1,490 @@
+/*
+ * 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.streaming
+
+import java.util.concurrent.{CancellationException, CompletableFuture, 
CountDownLatch, LinkedBlockingDeque, Semaphore, TimeUnit}
+import java.util.concurrent.atomic.{AtomicBoolean, AtomicLong, AtomicReference}
+import javax.annotation.concurrent.NotThreadSafe
+
+import scala.util.Try
+
+import io.netty.buffer.{ByteBuf, ByteBufOutputStream, CompositeByteBuf, 
Unpooled}
+import io.netty.channel.{ChannelFuture, ChannelOption}
+
+import org.apache.spark.{SparkContext, SparkEnv, StreamingShuffleTaskLocation, 
TaskContext}
+import org.apache.spark.internal.LogKeys
+import org.apache.spark.internal.config.{EXECUTOR_ID, 
STREAMING_SHUFFLE_CHECKSUM_ENABLED, 
STREAMING_SHUFFLE_NETWORK_BUFFER_MAX_WAIT_TIME_MS, 
STREAMING_SHUFFLE_NETWORK_BUFFER_SIZE, STREAMING_SHUFFLE_WRITER_MAX_MEMORY}
+import org.apache.spark.internal.config.Network.RPC_IO_THREADS
+import org.apache.spark.memory.{MemoryConsumer, MemoryMode}
+import org.apache.spark.network.TransportContext
+import org.apache.spark.network.client.TransportClient
+import org.apache.spark.network.netty.SparkTransportConf
+import org.apache.spark.network.server.TransportServer
+import org.apache.spark.network.shuffle.streaming.{DataMessage, 
ShuffleChecksum, StreamingShuffleMessage, StreamingShuffleMessageType, 
TerminationControlMessage}
+import org.apache.spark.scheduler.MapStatus
+import org.apache.spark.serializer.{JavaSerializerInstance, 
SerializationStream}
+import org.apache.spark.shuffle.{ShuffleHandle, ShuffleWriter}
+import org.apache.spark.util.{ErrorNotifier, Utils}
+
+class StreamingShuffleWriter[K, V](
+    handle: ShuffleHandle,
+    mapId: Long,
+    val context: TaskContext,
+    serverHandler: Option[StreamingShuffleServerHandler] = None,
+    private[streaming] val errorNotifier: ErrorNotifier = new ErrorNotifier())
+    extends ShuffleWriter[K, V] with TaskContextAwareLogging {
+  assert(SparkEnv.get.streamingShuffleOutputTracker.isDefined)
+  // Spark params.
+  private val conf = SparkEnv.get.conf
+  private val SEND_BUFFER_SIZE: Integer = 32 << 10 // 32 KB
+  private val RECV_BUFFER_SIZE: Integer = 512
+  // The target network buffer size
+  private val BUFFER_SIZE: Integer = 
conf.get(STREAMING_SHUFFLE_NETWORK_BUFFER_SIZE)
+  // The interval at which we flush pending messages.
+  private val MAX_BUFFERING_TIME_MS = 
conf.get(STREAMING_SHUFFLE_NETWORK_BUFFER_MAX_WAIT_TIME_MS)
+
+  // Shuffle details.
+  private val streamingShuffleHandle = 
handle.asInstanceOf[StreamingShuffleHandle[K, V, _]]
+  private val serializerInstance = 
streamingShuffleHandle.dependency.serializer.newInstance()
+  private val partitioner = streamingShuffleHandle.dependency.partitioner
+  private val numPartitions = partitioner.numPartitions
+  private val shuffleWriterId = context.partitionId()
+  // Total size of TCP buffers. Use Long math to avoid 32-bit overflow when 
numPartitions
+  // is large (numPartitions * buffer sizes can exceed Int.MaxValue).
+  private val TOTAL_TCPBUF_BYTES: Long =
+    numPartitions.toLong * (SEND_BUFFER_SIZE + RECV_BUFFER_SIZE)
+  // Total allowed memory for buffered rows, excluding TCP buffers.
+  private val MAX_BUFFER_BYTES: Long = math.max(numPartitions.toLong * 
BUFFER_SIZE * 2,
+    conf.get(STREAMING_SHUFFLE_WRITER_MAX_MEMORY).toLong - TOTAL_TCPBUF_BYTES)
+  require(MAX_BUFFER_BYTES >= BUFFER_SIZE && MAX_BUFFER_BYTES <= Int.MaxValue,
+    s"Streaming shuffle writer memory budget ($MAX_BUFFER_BYTES bytes) is 
invalid for " +
+      s"$numPartitions partitions; increase 
${STREAMING_SHUFFLE_WRITER_MAX_MEMORY.key} or " +
+      "reduce the number of partitions.")
+  // The per-partition floor (2 buffers per partition) can exceed the 
configured writerMaxMemory
+  // when the partition count is high; surface the effective budget so 
operators can see that the
+  // limit they set is not the one in force.
+  if (numPartitions.toLong * BUFFER_SIZE * 2 >
+      conf.get(STREAMING_SHUFFLE_WRITER_MAX_MEMORY).toLong - 
TOTAL_TCPBUF_BYTES) {
+    logWarning(log"Streaming shuffle writer effective memory budget " +

Review Comment:
   Tiny wording nit: this prints `MAX_BUFFER_BYTES` (which excludes TCP 
buffers) against the configured value, so when the floor only slightly wins, 
the two printed numbers may be equal or even show the "effective" value below 
the configured one, while the actual total in force (`MAX_BUFFER_BYTES + 
TOTAL_TCPBUF_BYTES`) does exceed it. Printing the sum as the effective figure 
would make the message literally accurate. Feel free to ignore.



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