You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@spark.apache.org by do...@apache.org on 2026/08/27 01:18:30 UTC

(spark) branch branch-4.1 updated: [SPARK-59008][K8S] Fix NPE in ExecutorPodsLifecycleManager when pod is deleted between get() calls

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

dongjoon-hyun pushed a commit to branch branch-4.1
in repository https://gitbox.apache.org/repos/asf/spark.git


The following commit(s) were added to refs/heads/branch-4.1 by this push:
     new 6c0d65852095 [SPARK-59008][K8S] Fix NPE in ExecutorPodsLifecycleManager when pod is deleted between get() calls
6c0d65852095 is described below

commit 6c0d6585209584491f417ee83afb2a812d3d4bc3
Author: Venkata krishnan Sowrirajan <ve...@gmail.com>
AuthorDate: Wed Aug 26 18:16:46 2026 -0700

    [SPARK-59008][K8S] Fix NPE in ExecutorPodsLifecycleManager when pod is deleted between get() calls
    
    ### What changes were proposed in this pull request?
    
    - Fetch the pod once in `ExecutorPodsLifecycleManager.removeExecutorFromK8s` instead of calling `podToDelete.get()` twice.
    
    ### Why are the changes needed?
    
    - `podToDelete` is a lazy `PodResource` handle, so each `get()` is a separate API server round trip. If the pod is deleted between the two calls, the second returns `null` and dereferencing `getMetadata` throws `NullPointerException: Cannot invoke "io.fabric8.kubernetes.api.model.Pod.getMetadata()" because the return value of "io.fabric8.kubernetes.client.dsl.PodResource.get()" is null`, thrown from `removeExecutorFromK8s` via `onFinalNonDeletedState` / `onNewSnapshots`.
    - Observed on a driver managing ~1000 executors during heavy pod churn. The exception aborts the K8s-side deletion for that executor, leaving the pod to be reaped on a later resync.
    - Introduced by SPARK-54197, which added the `deletionTimestamp` check as a second `get()` call rather than reusing the first result. The `&&` short-circuit guards against the first call returning `null`, but not against the pod disappearing between the two.
    
    ### Does this PR introduce _any_ user-facing change?
    
    - No.
    
    ### How was this patch tested?
    
    - New unit test in `ExecutorPodsLifecycleManagerSuite` stubbing `get()` to return the pod then `null` on successive calls. Fails on master with the NPE above, passes with this change. Full suite 10/10.
    
    ### Was this patch authored or co-authored using generative AI tooling?
    
    - Yes, using Claude with Opus 4.8.
    
    Closes #58291 from venkata91/SPARK-executor-pods-lifecycle-npe.
    
    Authored-by: Venkata krishnan Sowrirajan <ve...@gmail.com>
    Signed-off-by: Dongjoon Hyun <do...@apache.org>
    (cherry picked from commit b18e46d4fbdab45bb123efd1de1dbd1a619f1f34)
    Signed-off-by: Dongjoon Hyun <do...@apache.org>
---
 .../cluster/k8s/ExecutorPodsLifecycleManager.scala       |  6 ++++--
 .../cluster/k8s/ExecutorPodsLifecycleManagerSuite.scala  | 16 ++++++++++++++++
 2 files changed, 20 insertions(+), 2 deletions(-)

diff --git a/resource-managers/kubernetes/core/src/main/scala/org/apache/spark/scheduler/cluster/k8s/ExecutorPodsLifecycleManager.scala b/resource-managers/kubernetes/core/src/main/scala/org/apache/spark/scheduler/cluster/k8s/ExecutorPodsLifecycleManager.scala
index 3a508add6ccf..17db0dae5099 100644
--- a/resource-managers/kubernetes/core/src/main/scala/org/apache/spark/scheduler/cluster/k8s/ExecutorPodsLifecycleManager.scala
+++ b/resource-managers/kubernetes/core/src/main/scala/org/apache/spark/scheduler/cluster/k8s/ExecutorPodsLifecycleManager.scala
@@ -218,8 +218,10 @@ private[spark] class ExecutorPodsLifecycleManager(
           .inNamespace(namespace)
           .withName(updatedPod.getMetadata.getName)
 
-        if (podToDelete.get() != null &&
-            podToDelete.get.getMetadata.getDeletionTimestamp == null) {
+        // Fetch once: the pod can be removed between two `get` calls, making the second
+        // return null and NPE while dereferencing its metadata.
+        val fetchedPod = podToDelete.get()
+        if (fetchedPod != null && fetchedPod.getMetadata.getDeletionTimestamp == null) {
           podToDelete.delete()
         }
       } else if (!inactivatedPods.contains(execId) && !isPodInactive(updatedPod)) {
diff --git a/resource-managers/kubernetes/core/src/test/scala/org/apache/spark/scheduler/cluster/k8s/ExecutorPodsLifecycleManagerSuite.scala b/resource-managers/kubernetes/core/src/test/scala/org/apache/spark/scheduler/cluster/k8s/ExecutorPodsLifecycleManagerSuite.scala
index cdbcae050ceb..ab5034aac3d2 100644
--- a/resource-managers/kubernetes/core/src/test/scala/org/apache/spark/scheduler/cluster/k8s/ExecutorPodsLifecycleManagerSuite.scala
+++ b/resource-managers/kubernetes/core/src/test/scala/org/apache/spark/scheduler/cluster/k8s/ExecutorPodsLifecycleManagerSuite.scala
@@ -169,6 +169,22 @@ class ExecutorPodsLifecycleManagerSuite extends SparkFunSuite with BeforeAndAfte
     verify(namedExecutorPods(failedPod.getMetadata.getName), times(1)).delete()
   }
 
+  test("SPARK-59008: Pod deleted between the two get calls doesn't throw NPE.") {
+    val failedPod = failedExecutorWithoutDeletion(1)
+    val mockPodResource = mock(classOf[PodResource])
+    namedExecutorPods.put("spark-executor-1", mockPodResource)
+    // The pod is present on the first lookup and gone right after, as when the API server
+    // removes it concurrently with the driver's deletion attempt. Re-reading it would NPE.
+    when(mockPodResource.get()).thenReturn(failedPod, null.asInstanceOf[Pod])
+    snapshotsStore.updatePod(failedPod)
+    snapshotsStore.notifySubscribers()
+
+    val msg = exitReasonMessage(1, failedPod, 1)
+    val expectedLossReason = ExecutorExited(1, exitCausedByApp = true, msg)
+    verify(schedulerBackend, times(1)).doRemoveExecutor("1", expectedLossReason)
+    verify(namedExecutorPods(failedPod.getMetadata.getName), times(1)).delete()
+  }
+
   test("When the scheduler backend lists executor ids that aren't present in the cluster," +
     " remove those executors from Spark.") {
       when(schedulerBackend.getExecutorsWithRegistrationTs()).thenReturn(Map("1" -> 7L))


---------------------------------------------------------------------
To unsubscribe, e-mail: commits-unsubscribe@spark.apache.org
For additional commands, e-mail: commits-help@spark.apache.org