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

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


The following commit(s) were added to refs/heads/branch-4.x by this push:
     new 083df6652a7f [SPARK-58879][CORE] Avoid decommissioning executors that 
became busy
083df6652a7f is described below

commit 083df6652a7fa7ac046eb6bb2d373775c48bcec6
Author: Chao Sun <[email protected]>
AuthorDate: Thu Aug 20 08:58:40 2026 -0700

    [SPARK-58879][CORE] Avoid decommissioning executors that became busy
    
    ### Why are the changes needed?
    
    [SPARK-58879](https://issues.apache.org/jira/browse/SPARK-58879) is the 
graceful-decommission equivalent of 
[SPARK-9552](https://issues.apache.org/jira/browse/SPARK-9552). Dynamic 
allocation decides which executors have timed out using asynchronously updated 
idle information. An executor can receive new work between that decision and 
the request to remove it. The ordinary kill path handles this by checking 
whether the executor is still idle when `force = false`; the graceful path curr 
[...]
    
    For example, dynamic allocation can select executor E for idle removal just 
as a new stage arrives. The scheduler assigns a task to E and records it as 
running, but has not yet delivered `LaunchTask`. The graceful scale-down 
request then accepts E and starts decommissioning it. The already-assigned task 
can arrive after decommissioning has begun. This needlessly drains a busy 
executor and can cause task retries, including failed block writes when storage 
decommissioning is enabled.
    
    The missing guarantee is narrow: an ordinary idle-removal request must not 
accept an executor that has acquired new task assignments. Explicit host drains 
and other forced decommission requests still need to be able to retire busy 
executors.
    
    ### What changes were proposed in this PR?
    
    Core dynamic allocation now uses a separate idle-only decommission entry 
point. The coarse-grained backend checks the scheduler's current task 
assignments while holding the same scheduler/backend locks used to make offers, 
and keeps those locks until the existing decommission path has made the 
accepted executors unavailable for new offers.
    
    That gives the race two safe outcomes. If task assignment happens first, 
the removal request skips the busy executor and can try again later. If 
decommissioning happens first, subsequent offers cannot select that executor. 
The check uses scheduler bookkeeping, which already includes assigned tasks 
whose `LaunchTask` message has not been sent yet.
    
    The existing unconditional decommission method is unchanged. The new entry 
point delegates to it with only active, idle, deduplicated executor IDs, 
preserving cluster-manager overrides such as Kubernetes pod metadata updates. 
Idle-only requests reject unknown or already-retiring executors without queuing 
a deferred decommission; explicit requests retain their existing 
[pre-registration replay 
behavior](https://issues.apache.org/jira/browse/SPARK-41766).
    
    The new method's default declines all requests rather than invoking 
unconditional decommissioning. All in-tree clients inherit the coarse-grained 
implementation; custom clients that implement the allocation trait directly 
must override the new method to support graceful dynamic-allocation scale-down. 
This preserves the old three-argument method and its overrides without silently 
substituting abrupt removal for a custom graceful implementation. This adds no 
configuration or executor RP [...]
    
    ### How was this PR tested?
    
    Added deterministic regressions using a real `TaskSchedulerImpl` for both 
race orderings, ordinary and barrier tasks, rejected/duplicate requests, and 
explicit busy-executor decommissioning. Allocation-manager tests check 
accepted-subset accounting and later retries; Kubernetes tests check that only 
accepted IDs reach the existing metadata path.
    
    On JDK 17, all 83 tests in the three focused suites passed (68 core and 15 
Kubernetes), along with the four compile/test style checks:
    
    ```sh
    build/sbt -Djava.net.preferIPv4Stack=true -Pkubernetes \
      'set LocalProject("core") / Test / javaOptions += 
"-Djava.net.preferIPv4Stack=true"' \
      'set LocalProject("kubernetes") / Test / javaOptions += 
"-Djava.net.preferIPv4Stack=true"' \
      'core/testOnly 
org.apache.spark.scheduler.CoarseGrainedSchedulerBackendSuite 
org.apache.spark.ExecutorAllocationManagerSuite' \
      'kubernetes/testOnly 
org.apache.spark.scheduler.cluster.k8s.KubernetesClusterSchedulerBackendSuite' \
      'core/scalaStyleOnCompile' 'core/scalaStyleOnTest' \
      'kubernetes/scalaStyleOnCompile' 'kubernetes/scalaStyleOnTest'
    ```
    
    As a negative control, removing only 
`!scheduler.isExecutorBusy(executorId)` made both 
assignment-before-`LaunchTask` tests fail because the busy executor was 
accepted. A second, test-only negative control added an executor-ID selector 
containing both the accepted and rejected IDs; the focused Kubernetes assertion 
failed as expected. Both temporary changes were restored, their source 
checksums verified, and all 83 focused tests and four style checks passed 
again. The IPv4 option is a  [...]
    
    ### Does this PR introduce _any_ user-facing change?
    
    Yes. With core dynamic allocation and graceful decommissioning enabled, 
idle scale-down no longer retires an executor merely because an earlier idle 
observation became stale after new task assignment. Explicit and 
infrastructure-initiated decommissioning retain their existing behavior.
    
    ### Was this patch authored or co-authored using generative AI tooling?
    
    Generated-by: Codex
    
    Closes #58131 from sunchao/dev/chao/codex/spark-58879-idle-decommission.
    
    Authored-by: Chao Sun <[email protected]>
    Signed-off-by: Chao Sun <[email protected]>
    (cherry picked from commit b1ce1292b62601a12a5203bbcd9e09fad49a6cfc)
    Signed-off-by: Chao Sun <[email protected]>
---
 .../apache/spark/ExecutorAllocationClient.scala    |  13 +
 .../apache/spark/ExecutorAllocationManager.scala   |   5 +-
 .../cluster/CoarseGrainedSchedulerBackend.scala    |  15 ++
 .../spark/ExecutorAllocationManagerSuite.scala     |  50 +++-
 .../CoarseGrainedSchedulerBackendSuite.scala       | 290 ++++++++++++++++++++-
 .../KubernetesClusterSchedulerBackendSuite.scala   | 143 +++++++++-
 6 files changed, 496 insertions(+), 20 deletions(-)

diff --git 
a/core/src/main/scala/org/apache/spark/ExecutorAllocationClient.scala 
b/core/src/main/scala/org/apache/spark/ExecutorAllocationClient.scala
index c533f5cbc0b0..870a24a9b3b4 100644
--- a/core/src/main/scala/org/apache/spark/ExecutorAllocationClient.scala
+++ b/core/src/main/scala/org/apache/spark/ExecutorAllocationClient.scala
@@ -104,6 +104,19 @@ private[spark] trait ExecutorAllocationClient {
       countFailures = false)
   }
 
+  /**
+   * Decommission only executors which are still idle when the request is 
accepted.
+   * Implementations must check for assigned tasks and stop further task 
assignment atomically.
+   * The default declines all requests. Clients that support graceful 
dynamic-allocation
+   * scale-down must override this method and provide the atomicity guarantee.
+   *
+   * @param executorsAndDecomInfo identifiers of executors and decommission 
information
+   * @param adjustTargetNumExecutors whether to reduce the target number of 
executors
+   * @return the ids of the executors accepted for decommissioning
+   */
+  def decommissionExecutorsIfIdle(
+      executorsAndDecomInfo: Array[(String, ExecutorDecommissionInfo)],
+      adjustTargetNumExecutors: Boolean): Seq[String] = Seq.empty
 
   /**
    * Request that the cluster manager decommission the specified executor.
diff --git 
a/core/src/main/scala/org/apache/spark/ExecutorAllocationManager.scala 
b/core/src/main/scala/org/apache/spark/ExecutorAllocationManager.scala
index 57f2d21bc7b7..559b3fcf3712 100644
--- a/core/src/main/scala/org/apache/spark/ExecutorAllocationManager.scala
+++ b/core/src/main/scala/org/apache/spark/ExecutorAllocationManager.scala
@@ -584,10 +584,9 @@ private[spark] class ExecutorAllocationManager(
       if (decommissionEnabled) {
         val executorIdsWithoutHostLoss = executorIdsToBeRemoved.map(
           id => (id, ExecutorDecommissionInfo("spark scale down"))).toArray
-        client.decommissionExecutors(
+        client.decommissionExecutorsIfIdle(
           executorIdsWithoutHostLoss,
-          adjustTargetNumExecutors = false,
-          triggeredByExecutor = false)
+          adjustTargetNumExecutors = false)
       } else {
         client.killExecutors(executorIdsToBeRemoved.toSeq, 
adjustTargetNumExecutors = false,
           countFailures = false, force = false)
diff --git 
a/core/src/main/scala/org/apache/spark/scheduler/cluster/CoarseGrainedSchedulerBackend.scala
 
b/core/src/main/scala/org/apache/spark/scheduler/cluster/CoarseGrainedSchedulerBackend.scala
index e07cde82ffa0..2f957aead71d 100644
--- 
a/core/src/main/scala/org/apache/spark/scheduler/cluster/CoarseGrainedSchedulerBackend.scala
+++ 
b/core/src/main/scala/org/apache/spark/scheduler/cluster/CoarseGrainedSchedulerBackend.scala
@@ -624,6 +624,21 @@ class CoarseGrainedSchedulerBackend(scheduler: 
TaskSchedulerImpl, val rpcEnv: Rp
     executorsToDecommission.toImmutableArraySeq
   }
 
+  override def decommissionExecutorsIfIdle(
+      executorsAndDecomInfo: Array[(String, ExecutorDecommissionInfo)],
+      adjustTargetNumExecutors: Boolean): Seq[String] = withLock {
+    val idleExecutors = executorsAndDecomInfo.distinctBy(_._1).filter { case 
(executorId, _) =>
+      isExecutorActive(executorId) && !scheduler.isExecutorBusy(executorId)
+    }
+    if (idleExecutors.isEmpty) {
+      Seq.empty
+    } else {
+      // Keep both locks until the existing path marks these executors pending 
decommission.
+      // Use virtual dispatch so cluster-manager overrides receive only the 
filtered IDs.
+      decommissionExecutors(idleExecutors, adjustTargetNumExecutors, 
triggeredByExecutor = false)
+    }
+  }
+
   override def start(): Unit = {
     setupTokenManager()
     setupUserCredentialManager()
diff --git 
a/core/src/test/scala/org/apache/spark/ExecutorAllocationManagerSuite.scala 
b/core/src/test/scala/org/apache/spark/ExecutorAllocationManagerSuite.scala
index 0872d39df535..7f622dc91772 100644
--- a/core/src/test/scala/org/apache/spark/ExecutorAllocationManagerSuite.scala
+++ b/core/src/test/scala/org/apache/spark/ExecutorAllocationManagerSuite.scala
@@ -21,7 +21,8 @@ import java.util.concurrent.TimeUnit
 
 import scala.collection.mutable
 
-import org.mockito.ArgumentMatchers.any
+import org.mockito.ArgumentCaptor
+import org.mockito.ArgumentMatchers.{any, anyBoolean, eq => mockitoEq}
 import org.mockito.Mockito._
 import org.scalatest.PrivateMethodTester
 
@@ -1070,6 +1071,53 @@ class ExecutorAllocationManagerSuite extends 
SparkFunSuite {
     
assert(manager.executorAllocationManagerSource.exitedUnexpectedly.getCount() 
=== 1)
   }
 
+  test("SPARK-58879: dynamic allocation accounts only for accepted idle 
decommissions") {
+    val conf = createConf(0, 5, 0, decommissioningEnabled = true)
+      .set(config.DYN_ALLOCATION_TESTING, false)
+    when(client.requestTotalExecutors(any(), any(), any())).thenReturn(true)
+    when(client.decommissionExecutorsIfIdle(any(), anyBoolean()))
+      .thenReturn(Seq("executor-2"))
+    val manager = createManager(conf)
+    onExecutorAddedDefaultProfile(manager, "executor-1")
+    onExecutorAddedDefaultProfile(manager, "executor-2")
+
+    assert(removeExecutorsDefaultProfile(manager, Seq("executor-1", 
"executor-2")) ===
+      Seq("executor-2"))
+    assert(executorsDecommissioning(manager) === Set("executor-2"))
+    assert(executorsPendingToRemove(manager).isEmpty)
+
+    val requests = ArgumentCaptor.forClass(
+      classOf[Array[(String, ExecutorDecommissionInfo)]])
+    verify(client).decommissionExecutorsIfIdle(requests.capture(), 
mockitoEq(false))
+    assert(requests.getValue.toSeq === Seq(
+      "executor-1" -> ExecutorDecommissionInfo("spark scale down"),
+      "executor-2" -> ExecutorDecommissionInfo("spark scale down")))
+    verify(client, never()).decommissionExecutors(any(), anyBoolean(), 
anyBoolean())
+    verify(client, never()).killExecutors(any(), anyBoolean(), anyBoolean(), 
anyBoolean())
+
+    // A stale timeout can be rejected again without accounting the executor 
as removed.
+    when(client.decommissionExecutorsIfIdle(any(), anyBoolean()))
+      .thenReturn(Seq.empty[String])
+    assert(removeExecutorsDefaultProfile(manager, Seq("executor-1")).isEmpty)
+    assert(executorsDecommissioning(manager) === Set("executor-2"))
+
+    // The same executor remains eligible for a later request once it is 
actually idle.
+    when(client.decommissionExecutorsIfIdle(any(), anyBoolean()))
+      .thenReturn(Seq("executor-1"))
+    assert(removeExecutorsDefaultProfile(manager, Seq("executor-1")) === 
Seq("executor-1"))
+    assert(executorsDecommissioning(manager) === Set("executor-1", 
"executor-2"))
+  }
+
+  test("SPARK-58879: unsupported idle decommission does not force removal") {
+    val unsupportedClient = mock(classOf[ExecutorAllocationClient], 
CALLS_REAL_METHODS)
+    assert(unsupportedClient.decommissionExecutorsIfIdle(
+      Array("executor-1" -> ExecutorDecommissionInfo("spark scale down")),
+      adjustTargetNumExecutors = false).isEmpty)
+    verify(unsupportedClient, never()).decommissionExecutors(any(), 
anyBoolean(), anyBoolean())
+    verify(unsupportedClient, never())
+      .killExecutors(any(), anyBoolean(), anyBoolean(), anyBoolean())
+  }
+
   test("remove multiple executors") {
     val manager = createManager(createConf(5, 10, 5))
     (1 to 10).map(_.toString).foreach { id => 
onExecutorAddedDefaultProfile(manager, id) }
diff --git 
a/core/src/test/scala/org/apache/spark/scheduler/CoarseGrainedSchedulerBackendSuite.scala
 
b/core/src/test/scala/org/apache/spark/scheduler/CoarseGrainedSchedulerBackendSuite.scala
index 8c317661ef8a..6d01593f3e1a 100644
--- 
a/core/src/test/scala/org/apache/spark/scheduler/CoarseGrainedSchedulerBackendSuite.scala
+++ 
b/core/src/test/scala/org/apache/spark/scheduler/CoarseGrainedSchedulerBackendSuite.scala
@@ -18,6 +18,7 @@
 package org.apache.spark.scheduler
 
 import java.util.Properties
+import java.util.concurrent.{Callable, CountDownLatch, FutureTask, 
LinkedBlockingQueue, TimeUnit}
 import java.util.concurrent.atomic.{AtomicBoolean, AtomicReference}
 
 import scala.collection.mutable
@@ -580,6 +581,153 @@ class CoarseGrainedSchedulerBackendSuite extends 
SparkFunSuite with LocalSparkCo
     }
   }
 
+  Seq(false, true).foreach { isBarrier =>
+    test("SPARK-58879: idle decommission rejects tasks assigned before 
LaunchTask " +
+      s"(barrier=$isBarrier)") {
+      val numTasks = if (isBarrier) 2 else 1
+      val conf = new SparkConf().set(EXECUTOR_CORES, 2)
+      val backend = createDecommissionBackend(conf)
+      val scheduler = backend.taskScheduler
+      val executor = registerDecommissionExecutor(backend, "1", 2)
+      val decommissionCalled = new AtomicBoolean(false)
+      backend.beforeDecommission = (_, _, _) => decommissionCalled.set(true)
+      val launchEntered = new CountDownLatch(1)
+      val allowLaunch = new CountDownLatch(1)
+      executor.beforeLaunch = _ => {
+        launchEntered.countDown()
+        require(allowLaunch.await(30, TimeUnit.SECONDS), "LaunchTask was not 
released")
+      }
+      val taskSet = if (isBarrier) {
+        FakeTask.createBarrierTaskSet(numTasks)
+      } else {
+        FakeTask.createTaskSet(numTasks)
+      }
+      scheduler.submitTasks(taskSet)
+      backend.driverEndpoint.send(ReviveOffers)
+
+      var requestThread: Thread = null
+      try {
+        assert(launchEntered.await(10, TimeUnit.SECONDS))
+        assert(scheduler.runningTasksByExecutors("1") === numTasks)
+        val (thread, request) = startDecommissionRequest {
+          backend.decommissionExecutorsIfIdle(
+            Array("1" -> ExecutorDecommissionInfo("idle timeout")), false)
+        }
+        requestThread = thread
+        assert(request.get(10, TimeUnit.SECONDS).isEmpty)
+        assert(backend.isExecutorActive("1"))
+        assert(!executor.decommissionReceived)
+        assert(!decommissionCalled.get())
+      } finally {
+        allowLaunch.countDown()
+        if (requestThread != null) {
+          requestThread.join(TimeUnit.SECONDS.toMillis(10))
+          assert(!requestThread.isAlive)
+        }
+      }
+
+      val tasks = (0 until numTasks).map(_ => executor.nextTask())
+      flushDecommissionBackend(backend)
+      tasks.foreach(completeDecommissionTestTask(backend, _))
+      assert(!scheduler.isExecutorBusy("1"))
+      assert(backend.getExecutorAvailableCpus("1").contains(BigDecimal(2)))
+      assert(backend.decommissionExecutorsIfIdle(
+        Array("1" -> ExecutorDecommissionInfo("idle timeout")), false) === 
Seq("1"))
+      assert(executor.decommissionReceived)
+    }
+  }
+
+  test("SPARK-58879: idle decommission fences an executor before concurrent 
resource offers") {
+    val backend = createDecommissionBackend()
+    val retired = registerDecommissionExecutor(backend, "1")
+    val survivor = registerDecommissionExecutor(backend, "2")
+    backend.taskScheduler.submitTasks(FakeTask.createTaskSet(1))
+    val admissionEntered = new CountDownLatch(1)
+    val allowAdmission = new CountDownLatch(1)
+    val offerEntered = new CountDownLatch(1)
+    val locksHeld = new AtomicBoolean(false)
+    val decommissionReleased = new AtomicBoolean(false)
+    backend.beforeDecommission = (_, _, _) => {
+      locksHeld.set(Thread.holdsLock(backend.taskScheduler) && 
Thread.holdsLock(backend))
+      admissionEntered.countDown()
+      decommissionReleased.set(allowAdmission.await(30, TimeUnit.SECONDS))
+    }
+    backend.beforeOffers = () => offerEntered.countDown()
+
+    val (requestThread, request) = startDecommissionRequest {
+      backend.decommissionExecutorsIfIdle(
+        Array("1" -> ExecutorDecommissionInfo("idle timeout")), false)
+    }
+    try {
+      assert(admissionEntered.await(10, TimeUnit.SECONDS))
+      backend.driverEndpoint.send(ReviveOffers)
+      assert(offerEntered.await(10, TimeUnit.SECONDS))
+    } finally {
+      allowAdmission.countDown()
+      requestThread.join(TimeUnit.SECONDS.toMillis(10))
+      assert(!requestThread.isAlive)
+    }
+    assert(request.get(10, TimeUnit.SECONDS) === Seq("1"))
+    assert(locksHeld.get())
+    assert(decommissionReleased.get())
+    flushDecommissionBackend(backend)
+    assert(retired.launchedTasks.isEmpty)
+    assert(retired.decommissionReceived)
+    val task = survivor.nextTask()
+    assert(task.executorId === "2")
+    completeDecommissionTestTask(backend, task)
+  }
+
+  test("SPARK-58879: idle decommission skips duplicates and does not replay 
rejected requests") {
+    // Retention defaults to zero. Enable it so an incorrectly queued request 
is observable.
+    val conf = new SparkConf().set(SCHEDULER_MAX_RETAINED_UNKNOWN_EXECUTORS, 1)
+    val backend = createDecommissionBackend(conf)
+    val first = registerDecommissionExecutor(backend, "1")
+    val second = registerDecommissionExecutor(backend, "2")
+    val info = ExecutorDecommissionInfo("idle timeout")
+    val requests = mutable.ArrayBuffer.empty[(Seq[String], Boolean, Boolean)]
+    backend.beforeDecommission = (ids, adjustTarget, triggeredByExecutor) => {
+      requests += ((ids, adjustTarget, triggeredByExecutor))
+    }
+
+    assert(backend.decommissionExecutorsIfIdle(
+      Array("1" -> info, "1" -> info, "3" -> info), false) === Seq("1"))
+    assert(backend.decommissionExecutorsIfIdle(Array("1" -> info), 
false).isEmpty)
+    assert(requests.toSeq === Seq((Seq("1"), false, false)))
+    assert(first.decommissionReceived)
+    assert(!second.decommissionReceived)
+    assert(!backend.hasUnknownDecommission("1"))
+    assert(!backend.hasUnknownDecommission("3"))
+
+    val third = registerDecommissionExecutor(backend, "3")
+    assert(!third.decommissionReceived)
+    assert(backend.isExecutorActive("3"))
+
+    assert(backend.decommissionExecutors(Array("4" -> info), false, 
false).isEmpty)
+    assert(backend.hasUnknownDecommission("4"))
+    val fourth = registerDecommissionExecutor(backend, "4")
+    assert(fourth.decommissionReceived)
+    assert(!backend.isExecutorActive("4"))
+    assert(!backend.hasUnknownDecommission("4"))
+  }
+
+  test("SPARK-58879: forced decommission still accepts a busy executor") {
+    val backend = createDecommissionBackend()
+    val executor = registerDecommissionExecutor(backend, "1")
+    backend.taskScheduler.submitTasks(FakeTask.createTaskSet(1))
+    backend.driverEndpoint.send(ReviveOffers)
+    val task = executor.nextTask()
+    assert(backend.taskScheduler.isExecutorBusy("1"))
+
+    assert(backend.decommissionExecutors(
+      Array("1" -> ExecutorDecommissionInfo("host drain")), false, false) === 
Seq("1"))
+    assert(executor.decommissionReceived)
+    assert(!backend.isExecutorActive("1"))
+    completeDecommissionTestTask(backend, task)
+    assert(!backend.taskScheduler.isExecutorBusy("1"))
+    assert(executor.launchedTasks.isEmpty)
+  }
+
   test("SPARK-41766: New registered executor should receive decommission 
request" +
     " sent before registration") {
     val conf = new SparkConf()
@@ -782,6 +930,59 @@ class CoarseGrainedSchedulerBackendSuite extends 
SparkFunSuite with LocalSparkCo
     assert(store.get().bytes === Array[Byte](50, 50))
   }
 
+  private def createDecommissionBackend(
+      conf: SparkConf = new SparkConf()): DecommissionTestSchedulerBackend = {
+    
conf.setMaster(s"coarseclustermanager[${classOf[DecommissionTestSchedulerBackend].getName}]")
+      .setAppName("idle decommission test")
+      .set(EXECUTOR_INSTANCES, 0)
+      .set(DYN_ALLOCATION_ENABLED, false)
+      .set(DECOMMISSION_ENABLED, true)
+    sc = new SparkContext(conf)
+    sc.schedulerBackend.asInstanceOf[DecommissionTestSchedulerBackend]
+  }
+
+  private def registerDecommissionExecutor(
+      backend: DecommissionTestSchedulerBackend,
+      executorId: String,
+      cores: Int = 1)
+      : DecommissionTestExecutorRpcEndpointRef = {
+    val executor = new DecommissionTestExecutorRpcEndpointRef(sc.conf, 
executorId)
+    assert(backend.driverEndpoint.askSync[Boolean](
+      RegisterExecutor(executorId, executor, "localhost", cores, Map.empty, 
Map.empty,
+        Map.empty, ResourceProfile.DEFAULT_RESOURCE_PROFILE_ID)))
+    backend.driverEndpoint.send(LaunchedExecutor(executorId))
+    flushDecommissionBackend(backend)
+    executor
+  }
+
+  private def flushDecommissionBackend(backend: 
DecommissionTestSchedulerBackend): Unit = {
+    // Any synchronous request flushes earlier driver-endpoint messages. 
Ignore its result:
+    // executor "1" may not be registered or may already be retired.
+    backend.driverEndpoint.askSync[Boolean](IsExecutorAlive("1"))
+  }
+
+  private def completeDecommissionTestTask(
+      backend: DecommissionTestSchedulerBackend,
+      task: TaskDescription): Unit = {
+    val result = new DirectTaskResult[Int](
+      sc.env.serializer.newInstance().serialize(0), Seq.empty, 
Array.emptyLongArray)
+    val serializedResult = 
sc.env.closureSerializer.newInstance().serialize(result)
+    backend.driverEndpoint.send(StatusUpdate(
+      task.executorId, task.taskId, TaskState.FINISHED, new 
SerializableBuffer(serializedResult),
+      task.cpus, task.resources))
+    flushDecommissionBackend(backend)
+  }
+
+  private def startDecommissionRequest[T](body: => T): (Thread, FutureTask[T]) 
= {
+    val request = new FutureTask[T](new Callable[T] {
+      override def call(): T = body
+    })
+    val thread = new Thread(request, "idle-decommission-test")
+    thread.setDaemon(true)
+    thread.start()
+    (thread, request)
+  }
+
   private def testSubmitJob(sc: SparkContext, rdd: RDD[Int]): Unit = {
     sc.submitJob(
       rdd,
@@ -793,7 +994,7 @@ class CoarseGrainedSchedulerBackendSuite extends 
SparkFunSuite with LocalSparkCo
   }
 }
 
-/** Simple cluster manager that wires up our mock backend for the resource 
tests. */
+/** Cluster manager for the mock resource tests and real-scheduler 
decommission tests. */
 private class CSMockExternalClusterManager extends ExternalClusterManager {
 
   private var ts: TaskSchedulerImpl = _
@@ -804,12 +1005,18 @@ private class CSMockExternalClusterManager extends 
ExternalClusterManager {
   override def createTaskScheduler(
       sc: SparkContext,
       masterURL: String): TaskScheduler = {
-    ts = mock[TaskSchedulerImpl]
-    when(ts.sc).thenReturn(sc)
-    when(ts.applicationId()).thenReturn("appid1")
-    when(ts.applicationAttemptId()).thenReturn(Some("attempt1"))
-    when(ts.schedulingMode).thenReturn(SchedulingMode.FIFO)
-    when(ts.excludedNodes()).thenReturn(Set.empty[String])
+    masterURL match {
+      case MOCK_REGEX(backendClassName)
+          if backendClassName == 
classOf[DecommissionTestSchedulerBackend].getName =>
+        ts = new TaskSchedulerImpl(sc, sc.conf.get(TASK_MAX_FAILURES))
+      case _ =>
+        ts = mock[TaskSchedulerImpl]
+        when(ts.sc).thenReturn(sc)
+        when(ts.applicationId()).thenReturn("appid1")
+        when(ts.applicationAttemptId()).thenReturn(Some("attempt1"))
+        when(ts.schedulingMode).thenReturn(SchedulingMode.FIFO)
+        when(ts.excludedNodes()).thenReturn(Set.empty[String])
+    }
     ts
   }
 
@@ -837,6 +1044,75 @@ class TestCoarseGrainedSchedulerBackend(scheduler: 
TaskSchedulerImpl, override v
   def getTaskSchedulerImpl(): TaskSchedulerImpl = scheduler
 }
 
+private[spark] class DecommissionTestSchedulerBackend(
+    scheduler: TaskSchedulerImpl,
+    override val rpcEnv: RpcEnv)
+  extends CoarseGrainedSchedulerBackend(scheduler, rpcEnv) {
+
+  val taskScheduler = scheduler
+  @volatile var beforeOffers: () => Unit = () => ()
+  @volatile var beforeDecommission: (Seq[String], Boolean, Boolean) => Unit = 
(_, _, _) => ()
+
+  // Tests drive the real offer paths explicitly, without periodic or 
scheduler-triggered offers.
+  override protected def createDriverEndpoint(): DriverEndpoint = new 
DriverEndpoint {
+    override def onStart(): Unit = {}
+
+    override def receive: PartialFunction[Any, Unit] = {
+      case ReviveOffers =>
+        beforeOffers()
+        super.receive(ReviveOffers)
+      case message => super.receive(message)
+    }
+  }
+
+  override def reviveOffers(): Unit = {}
+
+  override def decommissionExecutors(
+      executorsAndDecomInfo: Array[(String, ExecutorDecommissionInfo)],
+      adjustTargetNumExecutors: Boolean,
+      triggeredByExecutor: Boolean): Seq[String] = {
+    beforeDecommission(
+      executorsAndDecomInfo.map(_._1).toSeq, adjustTargetNumExecutors, 
triggeredByExecutor)
+    super.decommissionExecutors(
+      executorsAndDecomInfo, adjustTargetNumExecutors, triggeredByExecutor)
+  }
+
+  def hasUnknownDecommission(executorId: String): Boolean = synchronized {
+    unknownExecutorsPendingDecommission.getIfPresent(executorId) != null
+  }
+}
+
+private[spark] class DecommissionTestExecutorRpcEndpointRef(
+    conf: SparkConf,
+    executorId: String) extends RpcEndpointRef(conf) {
+
+  val launchedTasks = new LinkedBlockingQueue[TaskDescription]()
+  @volatile var decommissionReceived = false
+  @volatile var beforeLaunch: TaskDescription => Unit = (_: TaskDescription) 
=> ()
+
+  override def address: RpcAddress = RpcAddress("localhost", 10000 + 
executorId.toInt)
+  override def name: String = s"executor-$executorId"
+
+  override def send(message: Any): Unit = message match {
+    case LaunchTask(data) =>
+      val task = TaskDescription.decode(data.value)
+      beforeLaunch(task)
+      launchedTasks.add(task)
+    case DecommissionExecutor => decommissionReceived = true
+    case _ =>
+  }
+
+  override def ask[T: ClassTag](message: Any, timeout: RpcTimeout): Future[T] 
= {
+    Future.successful(true.asInstanceOf[T])
+  }
+
+  def nextTask(): TaskDescription = {
+    val task = launchedTasks.poll(10, TimeUnit.SECONDS)
+    require(task != null, s"No task was launched on executor $executorId")
+    task
+  }
+}
+
 private[spark] class MockExecutorRpcEndpointRef(conf: SparkConf) extends 
RpcEndpointRef(conf) {
   // scalastyle:off executioncontextglobal
   import scala.concurrent.ExecutionContext.Implicits.global
diff --git 
a/resource-managers/kubernetes/core/src/test/scala/org/apache/spark/scheduler/cluster/k8s/KubernetesClusterSchedulerBackendSuite.scala
 
b/resource-managers/kubernetes/core/src/test/scala/org/apache/spark/scheduler/cluster/k8s/KubernetesClusterSchedulerBackendSuite.scala
index 1f425d16edfb..5ac0d66f4183 100644
--- 
a/resource-managers/kubernetes/core/src/test/scala/org/apache/spark/scheduler/cluster/k8s/KubernetesClusterSchedulerBackendSuite.scala
+++ 
b/resource-managers/kubernetes/core/src/test/scala/org/apache/spark/scheduler/cluster/k8s/KubernetesClusterSchedulerBackendSuite.scala
@@ -27,24 +27,26 @@ import io.fabric8.kubernetes.client.dsl.PodResource
 import io.fabric8.kubernetes.client.dsl.base.PatchContext
 import org.jmock.lib.concurrent.DeterministicScheduler
 import org.mockito.{ArgumentCaptor, Mock, MockitoAnnotations}
-import org.mockito.ArgumentMatchers.{any, eq => mockitoEq}
-import org.mockito.Mockito.{atLeastOnce, inOrder, mock, never, spy, verify, 
when}
+import org.mockito.ArgumentMatchers.{any, anyBoolean, eq => mockitoEq}
+import org.mockito.Mockito.{atLeastOnce, inOrder, mock, never, spy, times, 
verify, when}
 import org.scalatest.BeforeAndAfter
 
 import org.apache.spark.{SparkConf, SparkContext, SparkEnv, SparkException, 
SparkFunSuite}
 import org.apache.spark.deploy.k8s.Config._
 import org.apache.spark.deploy.k8s.Constants._
 import org.apache.spark.deploy.k8s.Fabric8Aliases._
+import 
org.apache.spark.internal.config.SCHEDULER_MAX_RETAINED_UNKNOWN_EXECUTORS
 import org.apache.spark.resource.{ResourceProfile, ResourceProfileManager}
-import org.apache.spark.rpc.{RpcCallContext, RpcEndpoint, RpcEndpointRef, 
RpcEnv}
-import org.apache.spark.scheduler.{ExecutorKilled, ExecutorLossReason, 
LiveListenerBus, TaskSchedulerImpl}
-import 
org.apache.spark.scheduler.cluster.CoarseGrainedClusterMessages.{RegisterExecutor,
 RemoveExecutor, StopDriver, StopExecutors}
+import org.apache.spark.rpc.{RpcAddress, RpcCallContext, RpcEndpoint, 
RpcEndpointRef, RpcEnv}
+import org.apache.spark.scheduler.{ExecutorDecommissionInfo, ExecutorKilled, 
ExecutorLossReason, LiveListenerBus, TaskSchedulerImpl}
+import 
org.apache.spark.scheduler.cluster.CoarseGrainedClusterMessages.{DecommissionExecutor,
 RegisterExecutor, RemoveExecutor, StopDriver, StopExecutors}
 import org.apache.spark.scheduler.cluster.CoarseGrainedSchedulerBackend
 import 
org.apache.spark.scheduler.cluster.k8s.ExecutorLifecycleTestUtils.TEST_SPARK_APP_ID
+import org.apache.spark.storage.{BlockManager, BlockManagerMaster}
 
 class KubernetesClusterSchedulerBackendSuite extends SparkFunSuite with 
BeforeAndAfter {
 
-  private val schedulerExecutorService = new DeterministicScheduler()
+  private var schedulerExecutorService: DeterministicScheduler = _
   private val sparkConf = new SparkConf(false)
     .set("spark.executor.instances", "3")
     .set("spark.app.id", TEST_SPARK_APP_ID)
@@ -57,6 +59,12 @@ class KubernetesClusterSchedulerBackendSuite extends 
SparkFunSuite with BeforeAn
   @Mock
   private var env: SparkEnv = _
 
+  @Mock
+  private var blockManager: BlockManager = _
+
+  @Mock
+  private var blockManagerMaster: BlockManagerMaster = _
+
   @Mock
   private var rpcEnv: RpcEnv = _
 
@@ -116,12 +124,17 @@ class KubernetesClusterSchedulerBackendSuite extends 
SparkFunSuite with BeforeAn
   private val defaultProfile = 
ResourceProfile.getOrCreateDefaultProfile(sparkConf)
 
   before {
+    schedulerExecutorService = new DeterministicScheduler()
     MockitoAnnotations.openMocks(this).close()
     when(taskScheduler.sc).thenReturn(sc)
+    when(taskScheduler.excludedNodes()).thenReturn(Set.empty[String])
     when(sc.conf).thenReturn(sparkConf)
+    when(sc.listenerBus).thenReturn(listenerBus)
     when(sc.resourceProfileManager).thenReturn(resourceProfileManager)
     when(sc.env).thenReturn(env)
     when(env.rpcEnv).thenReturn(rpcEnv)
+    when(env.blockManager).thenReturn(blockManager)
+    when(blockManager.master).thenReturn(blockManagerMaster)
     driverEndpoint = ArgumentCaptor.forClass(classOf[RpcEndpoint])
     when(
       rpcEnv.setupEndpoint(
@@ -134,7 +147,15 @@ class KubernetesClusterSchedulerBackendSuite extends 
SparkFunSuite with BeforeAn
     
when(configMapsOperations.inNamespace("default")).thenReturn(configMapsWithNamespace)
     
when(configMapsWithNamespace.resource(any[ConfigMap]())).thenReturn(configMapResource)
     when(podAllocator.driverPod).thenReturn(None)
-    schedulerBackendUnderTest = new KubernetesClusterSchedulerBackend(
+    schedulerBackendUnderTest = createSchedulerBackend()
+  }
+
+  after {
+    ResourceProfile.clearDefaultProfile()
+  }
+
+  private def createSchedulerBackend(): KubernetesClusterSchedulerBackend = {
+    new KubernetesClusterSchedulerBackend(
       taskScheduler,
       sc,
       kubernetesClient,
@@ -146,8 +167,35 @@ class KubernetesClusterSchedulerBackendSuite extends 
SparkFunSuite with BeforeAn
       pollEvents)
   }
 
-  after {
-    ResourceProfile.clearDefaultProfile()
+  private def registerExecutor(
+      backend: KubernetesClusterSchedulerBackend,
+      executorId: String): RpcEndpointRef = {
+    val executorEndpoint = mock(classOf[RpcEndpointRef])
+    when(executorEndpoint.address).thenReturn(RpcAddress("localhost", 10000 + 
executorId.toInt))
+    
backend.createDriverEndpoint().receiveAndReply(mock(classOf[RpcCallContext])).apply(
+      RegisterExecutor(executorId, executorEndpoint, s"host-$executorId", 1,
+        Map.empty, Map.empty, Map.empty, defaultProfile.id))
+    assert(backend.isExecutorActive(executorId))
+    executorEndpoint
+  }
+
+  private def withDecommissionMetadata(
+      f: KubernetesClusterSchedulerBackend => Unit): Unit = {
+    val keys = Seq(KUBERNETES_ALLOCATION_PODS_ALLOCATOR.key,
+      KUBERNETES_EXECUTOR_POD_DELETION_COST.key, 
SCHEDULER_MAX_RETAINED_UNKNOWN_EXECUTORS.key)
+    val originalValues = keys.map(key => key -> sparkConf.getOption(key))
+    sparkConf.set(KUBERNETES_ALLOCATION_PODS_ALLOCATOR, "deployment")
+    sparkConf.set(KUBERNETES_EXECUTOR_POD_DELETION_COST, 7)
+    sparkConf.set(SCHEDULER_MAX_RETAINED_UNKNOWN_EXECUTORS, 10)
+    try {
+      // The backend captures the unknown-executor cache size when it is 
constructed.
+      f(createSchedulerBackend())
+    } finally {
+      originalValues.foreach {
+        case (key, Some(value)) => sparkConf.set(key, value)
+        case (key, None) => sparkConf.remove(key)
+      }
+    }
   }
 
   test("Start all components") {
@@ -303,6 +351,83 @@ class KubernetesClusterSchedulerBackendSuite extends 
SparkFunSuite with BeforeAn
     sparkConf.remove(KUBERNETES_EXECUTOR_POD_DELETION_COST.key)
   }
 
+  test("SPARK-58879: idle decommission passes only selected executors to 
Kubernetes") {
+    withDecommissionMetadata { schedulerBackend =>
+      val backend = spy[KubernetesClusterSchedulerBackend](schedulerBackend)
+      val idleExecutor = registerExecutor(backend, "1")
+      val busyExecutor = registerExecutor(backend, "2")
+      when(taskScheduler.isExecutorBusy("2")).thenReturn(true)
+      when(podsWithNamespace.withLabel(SPARK_APP_ID_LABEL, TEST_SPARK_APP_ID))
+        .thenReturn(labeledPods)
+      when(labeledPods.withLabel(SPARK_ROLE_LABEL, SPARK_POD_EXECUTOR_ROLE))
+        .thenReturn(labeledPods)
+      when(labeledPods.withLabelIn(SPARK_EXECUTOR_ID_LABEL, 
"1")).thenReturn(labeledPods)
+      val podResource = mock(classOf[PodResource])
+      when(labeledPods.resources())
+        .thenAnswer(_ => java.util.stream.Stream.of[PodResource](podResource))
+      val decomInfo = ExecutorDecommissionInfo("test")
+
+      val accepted = backend.decommissionExecutorsIfIdle(
+        Array("1" -> decomInfo, "2" -> decomInfo, "3" -> decomInfo, "1" -> 
decomInfo),
+        adjustTargetNumExecutors = false)
+
+      assert(accepted === Seq("1"))
+      val requests = ArgumentCaptor.forClass(
+        classOf[Array[(String, ExecutorDecommissionInfo)]])
+      verify(backend).decommissionExecutors(
+        requests.capture(), mockitoEq(false), mockitoEq(false))
+      assert(requests.getValue.toSeq === Seq("1" -> decomInfo))
+      assert(!backend.isExecutorActive("1"))
+      assert(backend.isExecutorActive("2"))
+      verify(blockManagerMaster).decommissionBlockManagers(Seq("1"))
+      verify(idleExecutor).send(DecommissionExecutor)
+      verify(busyExecutor, never()).send(DecommissionExecutor)
+      verify(kubernetesClient, never()).pods()
+
+      schedulerExecutorService.runUntilIdle()
+      verify(labeledPods, times(2)).withLabel(SPARK_ROLE_LABEL, 
SPARK_POD_EXECUTOR_ROLE)
+      val executorIds = ArgumentCaptor.forClass(classOf[Array[String]])
+      verify(labeledPods, atLeastOnce()).withLabelIn(
+        mockitoEq(SPARK_EXECUTOR_ID_LABEL), executorIds.capture(): _*)
+      assert(executorIds.getAllValues.asScala.forall(_.toSeq == Seq("1")))
+      verify(labeledPods, times(2)).resources()
+      val patches = ArgumentCaptor.forClass(classOf[Pod])
+      verify(podResource, times(2)).patch(any(classOf[PatchContext]), 
patches.capture())
+      val appliedPods = patches.getAllValues.asScala
+      assert(appliedPods.exists { pod =>
+        Option(pod.getMetadata.getLabels).exists(_.get("soLong") == 
"cruelWorld")
+      })
+      assert(appliedPods.exists { pod =>
+        Option(pod.getMetadata.getAnnotations).exists(_.get(POD_DELETION_COST) 
== "7")
+      })
+    }
+  }
+
+  test("SPARK-58879: rejected idle decommission has no pod updates or replay") 
{
+    withDecommissionMetadata { schedulerBackend =>
+      val backend = spy[KubernetesClusterSchedulerBackend](schedulerBackend)
+      val busyExecutor = registerExecutor(backend, "1")
+      when(taskScheduler.isExecutorBusy("1")).thenReturn(true)
+      val decomInfo = ExecutorDecommissionInfo("test")
+
+      assert(backend.decommissionExecutorsIfIdle(
+        Array.empty[(String, ExecutorDecommissionInfo)],
+        adjustTargetNumExecutors = false).isEmpty)
+      assert(backend.decommissionExecutorsIfIdle(
+        Array("1" -> decomInfo, "2" -> decomInfo, "1" -> decomInfo),
+        adjustTargetNumExecutors = false).isEmpty)
+
+      val laterExecutor = registerExecutor(backend, "2")
+      schedulerExecutorService.runUntilIdle()
+      verify(backend, never()).decommissionExecutors(
+        any[Array[(String, ExecutorDecommissionInfo)]](), anyBoolean(), 
anyBoolean())
+      verify(blockManagerMaster, 
never()).decommissionBlockManagers(any[Seq[String]]())
+      verify(busyExecutor, never()).send(DecommissionExecutor)
+      verify(laterExecutor, never()).send(DecommissionExecutor)
+      verify(kubernetesClient, never()).pods()
+    }
+  }
+
   test("SPARK-34407: CoarseGrainedSchedulerBackend.stop may throw 
SparkException") {
     schedulerBackendUnderTest.start()
 


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

Reply via email to