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


##########
resource-managers/kubernetes/core/src/main/scala/org/apache/spark/deploy/k8s/Config.scala:
##########
@@ -673,6 +673,17 @@ private[spark] object Config extends Logging {
       .booleanConf
       .createWithDefault(true)
 
+  val USE_DRIVER_SCHEDULING_GATE =
+    ConfigBuilder("spark.kubernetes.submission.useDriverSchedulingGate")
+      .doc("If true, in cluster mode the driver pod is created with a 
scheduling gate that " +
+        "is removed once the resources the driver mounts exist. Removing the 
gate needs the " +
+        "`patch` verb on pods, and the scheduler must honor scheduling gates. 
If false, the " +
+        "driver pod can be scheduled before those resources exist.")
+      .version("4.4.0")
+      .withBindingPolicy(ConfigBindingPolicy.NOT_APPLICABLE)
+      .booleanConf
+      .createWithDefault(true)

Review Comment:
   **On your open question about this switch: I'd keep it, but default it to 
`false` in 4.4.0.** With `true`, every cluster-mode `spark-submit` needs 
`patch` on pods and a scheduler that honors gates, while the PR description 
says the race is rare ("without that delay the previous code reported none in 
20") and fixes itself ("The kubelet retries the mount, so the driver recovers; 
the cost is a short delay and a warning event").
   
   - A submitter without `patch` fails every submission (the gated pod is 
created, the PATCH gets a 403, and the pod is deleted), as you reproduced. For 
example, the kubeflow/spark-operator controller role grants pods only `get`, 
`list`, `watch`, `create`, `update` and `delete` ([`_helpers.tpl` 
L110-L121](https://github.com/kubeflow/spark-operator/blob/v2.5.2/charts/spark-operator-chart/templates/controller/_helpers.tpl#L110-L121)).
 Its image still bundles Spark 4.0.4 ([Dockerfile 
L17](https://github.com/kubeflow/spark-operator/blob/v2.5.2/Dockerfile#L17)), 
so moving it to 4.4 would need an RBAC change first, as 
[kubeflow/spark-operator#3240](https://github.com/kubeflow/spark-operator/pull/3240)
 did for the NetworkPolicy of 4.2.
   - The [versioning policy](https://spark.apache.org/versioning-policy.html) 
says feature releases have "No behavior or semantic changes (e.g. SQL 
semantics, execution behavior, configuration defaults)", with exceptions "case 
by case (e.g., security issues)". This is not a security fix, and unlike 
`spark.kubernetes.executor.useDriverPodIP`, it adds a failure mode to 
deployments that work today.
   - The switch itself is needed. The gate lives in `Client.run()` rather than 
in a feature step, so `spark.kubernetes.driver.pod.excludedFeatureSteps` can't 
turn it off, and it is the only way out for the case at KCA L141 and for 
YuniKorn before 1.7. kubeflow passes `spec.sparkConf` through to 
`spark-submit`, so its users can set it per application.
   
   So my suggestion is `createWithDefault(false)` with `.version("4.4.0")`, the 
RBAC bullet and the Pod Spec rows made conditional on it, the K8s integration 
tests run with the gate on so it stays covered, and the default flipped in a 
separate master-only (5.0.0) change with its own migration guide entry. Keeping 
`true` and making this PR master-only would be consistent too. This refines my 
[round-2 
comment](https://github.com/apache/spark/pull/58187#discussion_r4300454516), 
where I only asked for a way back to the previous flow.



##########
resource-managers/kubernetes/core/src/test/scala/org/apache/spark/deploy/k8s/submit/ClientSuite.scala:
##########
@@ -180,28 +210,52 @@ class ClientSuite extends SparkFunSuite with 
BeforeAndAfter {
     
when(podOperations.inNamespace(kconf.namespace)).thenReturn(podsWithNamespace)
     when(podsWithNamespace.withName(POD_NAME)).thenReturn(namedPods)
 
+    events.clear()
     createdPodArgumentCaptor = ArgumentCaptor.forClass(classOf[Pod])
     createdResourcesArgumentCaptor = 
ArgumentCaptor.forClass(classOf[Array[HasMetadata]])
-    when(podsWithNamespace.resource(fullExpectedPod())).thenReturn(namedPods)
+    patchContextCaptor = ArgumentCaptor.forClass(classOf[PatchContext])
+    patchBodyCaptor = ArgumentCaptor.forClass(classOf[String])
+    
when(podsWithNamespace.resource(createdPodArgumentCaptor.capture())).thenReturn(namedPods)
+    
when(podOperations.resource(ArgumentMatchers.any[Pod]())).thenReturn(namedPods)

Review Comment:
   **Since this stub returns the same `namedPods` for any pod, no test checks 
which pod object the gate PATCH (KCA L206) and its failure-path DELETE (KCA 
L214) go through.** Replacing `resource(createdDriverPod)` with 
`resource(podToCreate)` there passes every test. The UID check at L453-L455 
covers only the post-resource delete at KCA L197.
   
   That matters because `podToCreate` keeps the pod template's 
`metadata.namespace`. `loadPodFromTemplate` reads the template as is 
(`PodBuilderSuite` L190 expects the template's namespace to survive), and only 
the CREATE's `inNamespace(conf.namespace)` replaces it, on a copy. With a 
template in `team-a` and `spark.kubernetes.namespace=spark`, such a change 
would send the PATCH to `team-a` and fail with a 403 or 404 after all resources 
were created. With L214 changed too, it would leave the gated driver behind.
   
   The code here is right. Could we capture the `podOperations.resource(...)` 
arguments in the L319 and L472 tests and check the UID, as L453-L455 does?



##########
resource-managers/kubernetes/core/src/main/scala/org/apache/spark/deploy/k8s/submit/KubernetesClientApplication.scala:
##########
@@ -135,6 +136,21 @@ private[spark] class Client(
       .build()
     val driverPodName = resolvedDriverPod.getMetadata.getName
 
+    // The API server rejects a pod that has both spec.nodeName and scheduling 
gates, and a pod
+    // with spec.nodeName skips the scheduler anyway, so only pods the 
scheduler places are gated.
+    val gateDriverPod = conf.get(USE_DRIVER_SCHEDULING_GATE) &&

Review Comment:
   **On a cluster whose mutating admission sets `spec.nodeName`, every gated 
driver pod is now rejected at CREATE.** This check looks at the pod Spark 
builds, before admission, but the API server validates the pod after the 
mutating webhooks have run, and `ValidatePodCreate` rejects a pod that has both 
`nodeName` and scheduling gates ([validation.go 
L5609-L5612](https://github.com/kubernetes/kubernetes/blob/v1.35.0/pkg/apis/core/validation/validation.go#L5609-L5612)).
   
   For example, Portainer KubeSolo v1.2.2 (Kubernetes 1.35.7-k3s1) has no 
kube-scheduler. Its NodeSetter webhook "sets `spec.nodeName` on every new pod" 
([README 
L39](https://github.com/portainer/kubesolo/blob/v1.2.2/README.md?plain=1#L39)) 
on pod CREATE ([config.go 
L34-L45](https://github.com/portainer/kubesolo/blob/v1.2.2/pkg/kubernetes/webhook/config.go#L34-L45),
 [webhooks.go 
L133-L135](https://github.com/portainer/kubesolo/blob/v1.2.2/pkg/kubernetes/webhook/webhooks.go#L133-L135)).
 With the default configuration:
   
   - every cluster-mode submission fails with `Pod "<name>" is invalid: 
spec.nodeName: Forbidden: cannot be set until all schedulingGates have been 
cleared` (422), where master succeeds;
   - the catch block at L171-L175 logs the `kubectl auth can-i create pod` 
hint, which doesn't apply;
   - granting `patch` on pods, which the migration guide offers, doesn't help, 
because the CREATE fails first. Only 
`spark.kubernetes.submission.useDriverSchedulingGate=false` or a `nodeName` in 
the pod template avoids it.
   
   Could we mention this case in the docs next to the YuniKorn note? It is also 
one more reason for the default discussed at `Config.scala` L685.



##########
resource-managers/kubernetes/core/src/test/scala/org/apache/spark/deploy/k8s/submit/ClientSuite.scala:
##########
@@ -223,12 +277,210 @@ class ClientSuite extends SparkFunSuite with 
BeforeAndAfter {
     val configMap = configMaps.head
     assert(configMap.getMetadata.getName ===
       KubernetesClientUtils.configMapNameDriver)
+    assert(configMap.getMetadata.getName === mountedConfigMapName)
     assert(configMap.getImmutable())
     assert(configMap.getData.containsKey(SPARK_CONF_FILE_NAME))
     
assert(configMap.getData.get(SPARK_CONF_FILE_NAME).contains("conf1key=conf1value"))
     
assert(configMap.getData.get(SPARK_CONF_FILE_NAME).contains("conf2key=conf2value"))
   }
 
+  test("SPARK-38079: the driver pod is created gated, and the gate is removed 
last") {
+    newClient().run()
+
+    val configMapName = KubernetesClientUtils.configMapNameDriver
+    assert(events.toSeq === Seq(
+      "list()", "apply",
+      "create pod",
+      "list()", "apply",
+      s"list(Secret/secret+owner,ConfigMap/$configMapName+owner)", "apply",
+      "remove gate"))
+    
assert(createdPodArgumentCaptor.getValue.getSpec.getSchedulingGates.asScala.map(_.getName)
 ===
+      Seq(DRIVER_RESOURCES_SCHEDULING_GATE))
+  }
+
+  test("SPARK-38079: pre resources are applied before the gated pod and owned 
after it") {
+    val preResKconf = KubernetesTestConf.createDriverConf(
+      resourceNamePrefix = Some(KUBERNETES_RESOURCE_PREFIX))
+    when(driverBuilder.buildFromFeatures(preResKconf, kubernetesClient))
+      .thenReturn(BUILT_KUBERNETES_SPEC_WITH_PRERES)
+    kconf = preResKconf
+
+    newClient().run()
+
+    val configMapName = KubernetesClientUtils.configMapNameDriver
+    assert(events.toSeq === Seq(
+      "list(CustomResourceDefinition/preCRD)", "apply",
+      "create pod",
+      "list(CustomResourceDefinition/preCRD+owner)", "apply",
+      s"list(Secret/secret+owner,ConfigMap/$configMapName+owner)", "apply",
+      "remove gate"))
+  }
+
+  test("SPARK-38079: the gate is removed by name with an idempotent strategic 
merge patch") {
+    newClient().run()
+
+    verify(namedPods).patch(
+      ArgumentMatchers.any[PatchContext](), ArgumentMatchers.any[String]())
+    assert(patchContextCaptor.getValue.getPatchType === 
PatchType.STRATEGIC_MERGE)
+    assert(patchBodyCaptor.getValue ===
+      s"""{"spec":{"schedulingGates":[{"$$patch":"delete",""" +
+        s""""name":"$DRIVER_RESOURCES_SCHEDULING_GATE"}]}}""")
+  }
+
+  test("SPARK-38079: gates already on the pod template are kept next to the 
Spark gate") {
+    val templateGate = "example.com/keep-me"
+    val podWithGate = new PodBuilder(BUILT_DRIVER_POD)
+      .editSpec()
+        .addNewSchedulingGate(templateGate)
+        .endSpec()
+      .build()
+    when(driverBuilder.buildFromFeatures(kconf, kubernetesClient)).thenReturn(
+      KubernetesDriverSpec(
+        SparkPod(podWithGate, BUILT_DRIVER_CONTAINER),
+        Nil,
+        ADDITIONAL_RESOURCES,
+        RESOLVED_JAVA_OPTIONS))
+
+    newClient().run()
+
+    
assert(createdPodArgumentCaptor.getValue.getSpec.getSchedulingGates.asScala.map(_.getName)
 ===
+      Seq(templateGate, DRIVER_RESOURCES_SCHEDULING_GATE))
+    assert(!patchBodyCaptor.getValue.contains(templateGate))
+  }
+
+  test("SPARK-38079: the gate patch leaves a gate it does not name in place") {
+    // A fake API server that applies the patch body: only the named gate may 
disappear.

Review Comment:
   **Two new comments claim server-side behavior that these mocks can't show.**
   
   - Here, nothing applies the patch body. `remaining` (L374-L375) is the 
created pod's gates minus the name the test has just asserted, and every 
assertion follows from L325-L327 (the body is a constant) and L346-L347. The 
test never reads the patch type either. With `PatchType.JSON_MERGE`, a real API 
server would replace the whole list with Spark's gate alone, dropping the 
template's gate, yet this test passes; only L324 in the L319 test catches that. 
Could we fold what this test is meant to show into the L330 test and drop it, 
together with the module's only `ObjectMapper`?
   - L452 says the deleted pod is "the one this submission created, not some 
other pod of that name", but `resource(createdDriverPod).delete()` sends 
`DELETE .../pods/<name>` with no UID precondition ([OperationSupport 
L300-L308](https://github.com/fabric8io/kubernetes-client/blob/v7.9.0/kubernetes-client/src/main/java/io/fabric8/kubernetes/client/dsl/internal/OperationSupport.java#L300-L308)),
 the same request as `withName(name).delete()`. L453-L455 check which object 
reached the mock, not which pod the server deletes. Something like "The delete 
goes through the pod returned by create()" would be accurate.



##########
resource-managers/kubernetes/core/src/test/scala/org/apache/spark/deploy/k8s/submit/ClientSuite.scala:
##########
@@ -223,12 +277,210 @@ class ClientSuite extends SparkFunSuite with 
BeforeAndAfter {
     val configMap = configMaps.head
     assert(configMap.getMetadata.getName ===
       KubernetesClientUtils.configMapNameDriver)
+    assert(configMap.getMetadata.getName === mountedConfigMapName)
     assert(configMap.getImmutable())
     assert(configMap.getData.containsKey(SPARK_CONF_FILE_NAME))
     
assert(configMap.getData.get(SPARK_CONF_FILE_NAME).contains("conf1key=conf1value"))
     
assert(configMap.getData.get(SPARK_CONF_FILE_NAME).contains("conf2key=conf2value"))
   }
 
+  test("SPARK-38079: the driver pod is created gated, and the gate is removed 
last") {
+    newClient().run()
+
+    val configMapName = KubernetesClientUtils.configMapNameDriver
+    assert(events.toSeq === Seq(
+      "list()", "apply",
+      "create pod",
+      "list()", "apply",
+      s"list(Secret/secret+owner,ConfigMap/$configMapName+owner)", "apply",
+      "remove gate"))
+    
assert(createdPodArgumentCaptor.getValue.getSpec.getSchedulingGates.asScala.map(_.getName)
 ===
+      Seq(DRIVER_RESOURCES_SCHEDULING_GATE))
+  }
+
+  test("SPARK-38079: pre resources are applied before the gated pod and owned 
after it") {
+    val preResKconf = KubernetesTestConf.createDriverConf(
+      resourceNamePrefix = Some(KUBERNETES_RESOURCE_PREFIX))
+    when(driverBuilder.buildFromFeatures(preResKconf, kubernetesClient))
+      .thenReturn(BUILT_KUBERNETES_SPEC_WITH_PRERES)
+    kconf = preResKconf
+
+    newClient().run()
+
+    val configMapName = KubernetesClientUtils.configMapNameDriver
+    assert(events.toSeq === Seq(
+      "list(CustomResourceDefinition/preCRD)", "apply",
+      "create pod",
+      "list(CustomResourceDefinition/preCRD+owner)", "apply",
+      s"list(Secret/secret+owner,ConfigMap/$configMapName+owner)", "apply",
+      "remove gate"))
+  }
+
+  test("SPARK-38079: the gate is removed by name with an idempotent strategic 
merge patch") {
+    newClient().run()
+
+    verify(namedPods).patch(
+      ArgumentMatchers.any[PatchContext](), ArgumentMatchers.any[String]())
+    assert(patchContextCaptor.getValue.getPatchType === 
PatchType.STRATEGIC_MERGE)
+    assert(patchBodyCaptor.getValue ===
+      s"""{"spec":{"schedulingGates":[{"$$patch":"delete",""" +
+        s""""name":"$DRIVER_RESOURCES_SCHEDULING_GATE"}]}}""")
+  }
+
+  test("SPARK-38079: gates already on the pod template are kept next to the 
Spark gate") {
+    val templateGate = "example.com/keep-me"
+    val podWithGate = new PodBuilder(BUILT_DRIVER_POD)
+      .editSpec()
+        .addNewSchedulingGate(templateGate)
+        .endSpec()
+      .build()
+    when(driverBuilder.buildFromFeatures(kconf, kubernetesClient)).thenReturn(
+      KubernetesDriverSpec(
+        SparkPod(podWithGate, BUILT_DRIVER_CONTAINER),
+        Nil,
+        ADDITIONAL_RESOURCES,
+        RESOLVED_JAVA_OPTIONS))
+
+    newClient().run()
+
+    
assert(createdPodArgumentCaptor.getValue.getSpec.getSchedulingGates.asScala.map(_.getName)
 ===
+      Seq(templateGate, DRIVER_RESOURCES_SCHEDULING_GATE))
+    assert(!patchBodyCaptor.getValue.contains(templateGate))
+  }
+
+  test("SPARK-38079: the gate patch leaves a gate it does not name in place") {
+    // A fake API server that applies the patch body: only the named gate may 
disappear.
+    val templateGate = "example.com/keep-me"
+    val podWithGate = new PodBuilder(BUILT_DRIVER_POD)
+      .editSpec()
+        .addNewSchedulingGate(templateGate)
+        .endSpec()
+      .build()
+    when(driverBuilder.buildFromFeatures(kconf, kubernetesClient)).thenReturn(
+      KubernetesDriverSpec(
+        SparkPod(podWithGate, BUILT_DRIVER_CONTAINER),
+        Nil,
+        ADDITIONAL_RESOURCES,
+        RESOLVED_JAVA_OPTIONS))
+
+    newClient().run()
+
+    val patch = new com.fasterxml.jackson.databind.ObjectMapper()
+      .readTree(patchBodyCaptor.getValue)
+    val ops = patch.at("/spec/schedulingGates").elements().asScala.toSeq
+    assert(ops.size === 1, "the patch must name exactly one gate")
+    assert(ops.head.get("$patch").asText === "delete")
+    assert(ops.head.get("name").asText === DRIVER_RESOURCES_SCHEDULING_GATE)
+    val remaining = 
createdPodArgumentCaptor.getValue.getSpec.getSchedulingGates.asScala
+      .map(_.getName).filterNot(_ == ops.head.get("name").asText)
+    assert(remaining === Seq(templateGate))
+  }
+
+  test("SPARK-38079: disabling the scheduling gate keeps the previous flow and 
needs no patch") {
+    val sparkConf = new SparkConf(false)
+      .set(Config.USE_DRIVER_SCHEDULING_GATE, false)
+    kconf = KubernetesTestConf.createDriverConf(
+      sparkConf = sparkConf, resourceNamePrefix = 
Some(KUBERNETES_RESOURCE_PREFIX))
+    when(driverBuilder.buildFromFeatures(kconf, 
kubernetesClient)).thenReturn(BUILT_KUBERNETES_SPEC)
+
+    newClient().run()
+
+    
assert(createdPodArgumentCaptor.getValue.getSpec.getSchedulingGates.isEmpty)
+    verify(namedPods, never()).patch(
+      ArgumentMatchers.any[PatchContext](), ArgumentMatchers.any[String]())
+    assert(!events.contains("remove gate"))
+  }
+
+  test("SPARK-38079: a pod pinned with spec.nodeName is neither gated nor 
patched") {
+    val pinnedPod = new PodBuilder(BUILT_DRIVER_POD)
+      .editSpec()
+        .withNodeName("some-node")
+        .endSpec()
+      .build()
+    when(driverBuilder.buildFromFeatures(kconf, kubernetesClient)).thenReturn(
+      KubernetesDriverSpec(
+        SparkPod(pinnedPod, BUILT_DRIVER_CONTAINER),
+        Nil,
+        ADDITIONAL_RESOURCES,
+        RESOLVED_JAVA_OPTIONS))
+
+    newClient().run()
+
+    val created = createdPodArgumentCaptor.getValue
+    assert(created.getSpec.getNodeName === "some-node")
+    assert(created.getSpec.getSchedulingGates.isEmpty)
+    verify(namedPods, never()).patch(
+      ArgumentMatchers.any[PatchContext](), ArgumentMatchers.any[String]())
+    assert(events.toSeq === Seq(
+      "list()", "apply",
+      "create pod",
+      "list()", "apply",
+      
s"list(Secret/secret+owner,ConfigMap/${KubernetesClientUtils.configMapNameDriver}+owner)",
+      "apply"))
+  }
+
+  test("SPARK-38079: a failure creating the pod deletes nothing but the pre 
resources") {
+    val failure = new RuntimeException("create failed")
+    doAnswer(answer {
+      events += "create pod"
+      throw failure
+    }).when(namedPods).create()
+
+    val thrown = intercept[RuntimeException](newClient().run())
+
+    assert(thrown eq failure)
+    verify(namedPods, never()).delete()
+    verify(namedPods, never()).patch(
+      ArgumentMatchers.any[PatchContext](), ArgumentMatchers.any[String]())
+    assert(events.toSeq === Seq("list()", "apply", "create pod", "list()", 
"delete resources"))
+  }
+
+  test("SPARK-38079: a failure applying the post resources deletes only the 
pod") {
+    val failure = new RuntimeException("apply failed")
+    val failing = mock[RESOURCE_LIST]
+    when(failing.forceConflicts()).thenReturn(failing)
+    when(failing.serverSideApply()).thenThrow(failure)
+    resourceListFor = resources => if 
(resources.exists(_.isInstanceOf[Secret])) failing
+      else resourceList
+
+    val thrown = intercept[RuntimeException](newClient().run())
+
+    assert(thrown eq failure)
+    assert(events.last === "delete pod")
+    assert(!events.contains("remove gate"))
+    assert(!events.contains("delete resources"))
+    // The pod deleted is the one this submission created, not some other pod 
of that name.
+    val deleted = ArgumentCaptor.forClass(classOf[Pod])
+    verify(podOperations).resource(deleted.capture())
+    assert(deleted.getValue.getMetadata.getUid === DRIVER_POD_UID)
+  }
+
+  test("SPARK-38079: a failure deleting the pod does not hide the original 
failure") {
+    val failure = new RuntimeException("apply failed")
+    val failing = mock[RESOURCE_LIST]
+    when(failing.forceConflicts()).thenReturn(failing)
+    when(failing.serverSideApply()).thenThrow(failure)
+    resourceListFor = resources => if 
(resources.exists(_.isInstanceOf[Secret])) failing
+      else resourceList
+    when(namedPods.delete()).thenThrow(new RuntimeException("delete failed"))
+
+    val thrown = intercept[RuntimeException](newClient().run())
+
+    assert(thrown eq failure)
+  }
+
+  test("SPARK-38079: a failure removing the gate deletes the pod and 
propagates") {
+    val failure = new RuntimeException("patch failed")
+    when(namedPods.patch(ArgumentMatchers.any[PatchContext](), 
ArgumentMatchers.any[String]()))

Review Comment:
   **No test makes the DELETE fail after a failed gate PATCH, so the new guard 
at KCA L213-L215 is untested, and this `when(...).thenThrow` re-stub runs the 
`before` answer.**
   
   - The only delete-failure test (L458) fails the post-resource apply, so it 
never reaches the PATCH, and in this test the delete succeeds. Replacing 
L213-L215 with a bare delete keeps all 17 tests green, although a failed DELETE 
would then replace the PATCH failure. The PR description's "each failure path" 
is also broader than the tests: the pre-resource apply failure (KCA L156-L164) 
and the owner-refresh failure (KCA L178-L187) have none.
   - `when(namedPods.patch(any, any))` here and `when(namedPods.delete())` at 
L465 call the mock while stubbing, so Mockito runs the L234-L242 answers 
([MockHandlerImpl 
L91-L103](https://github.com/mockito/mockito/blob/v5.23.0/mockito-core/src/main/java/org/mockito/internal/handler/MockHandlerImpl.java#L91-L103)).
 `events` then starts with "remove gate" or "delete pod", the patch captors 
hold `null`, and the call that `run()` really makes isn't recorded. No 
assertion in these two tests reads them today, but the checks the other tests 
use, such as `!events.contains("remove gate")` or `events.last === "delete 
pod"`, would give the wrong answer here.
   
   Could we stub these with `doAnswer(answer { events += "remove gate"; throw 
failure }).when(namedPods).patch(...)`, as L424 does for `create()`, and add a 
case where both the PATCH and the DELETE fail?



##########
docs/core-migration-guide.md:
##########
@@ -26,6 +26,8 @@ license: |
 
 - Since Spark 4.4, reading an RDD checkpoint ignores names beginning with 
`part-` unless the suffix consists of one or more ASCII digits. Previously, 
names such as `part-00000.bak`, `part-backup`, or `part-` caused checkpoint 
recovery to fail with `NumberFormatException`. Validation of numeric partition 
names and the contiguous partition sequence is unchanged.
 
+- Since Spark 4.4, in Kubernetes cluster mode `spark-submit` creates the 
driver pod with the scheduling gate `spark.apache.org/driver-resources` and 
removes it once the resources the driver mounts exist, so the driver can no 
longer start before its config map. Removing the gate requires the `patch` verb 
on pods, which the account running `spark-submit` may not have; without it the 
submission fails and the driver pod is deleted. A driver pod whose template 
sets `spec.nodeName` is not gated and behaves as before. To restore the legacy 
behavior, you can set `spark.kubernetes.submission.useDriverSchedulingGate` to 
`false`, or grant `patch` on pods to the account that runs `spark-submit`.

Review Comment:
   **This entry has three inaccuracies.**
   
   - "To restore the legacy behavior, you can set ... to `false`, or grant 
`patch` on pods": granting `patch` keeps the gated flow and only lets it 
succeed. Every other "To restore the legacy behavior" entry in this file names 
only the setting that reverts the change.
   - It omits the scheduler requirement that the RBAC bullet, the config row, 
the `Config.scala` doc and the PR description all state. That is the case an 
upgrader can't diagnose: on YuniKorn before 1.7, granting `patch` turns the 403 
into a driver that stays `Pending`.
   - "so the driver can no longer start before its config map": it never could. 
The kubelet waits for the conf volume before it starts any container 
([kubelet.go 
L2120](https://github.com/kubernetes/kubernetes/blob/v1.35.0/pkg/kubelet/kubelet.go#L2120)).
 What changes is that the pod is no longer scheduled before its config map 
exists, which avoids the transient `FailedMount` events.
   
   If the default stays `true` (see `Config.scala` L685), how about something 
like this?
   
   ```suggestion
   - Since Spark 4.4, in Kubernetes cluster mode `spark-submit` creates the 
driver pod with the scheduling gate `spark.apache.org/driver-resources` and 
removes it once the resources it creates for the driver exist, so the driver 
pod is no longer scheduled before its config map exists. Removing the gate 
requires the `patch` verb on pods, which the account running `spark-submit` may 
not have; without it the submission fails and the driver pod is deleted. A 
scheduler that does not honor scheduling gates may leave the gated driver pod 
pending; for example, Apache YuniKorn supports `spec.schedulingGates` since 
v1.7.0. A driver pod that sets `spec.nodeName` is not gated and behaves as 
before. To restore the legacy behavior, you can set 
`spark.kubernetes.submission.useDriverSchedulingGate` to `false`. To keep the 
gate, grant `patch` on pods to the account that runs `spark-submit`.
   ```



##########
docs/running-on-kubernetes.md:
##########
@@ -56,6 +56,12 @@ you may set up a test cluster on your local machine using
 * You must have appropriate permissions to list, create, edit and delete
 [pods](https://kubernetes.io/docs/concepts/workloads/pods/) in your cluster. 
You can verify that you can list these resources
 by running `kubectl auth can-i <list|create|edit|delete> pods`.
+  * In cluster mode, `spark-submit` also needs the `patch` verb on pods: it 
creates the driver pod with a
+    [scheduling 
gate](https://kubernetes.io/docs/concepts/scheduling-eviction/pod-scheduling-readiness/)
 and removes
+    the gate once the resources the driver mounts exist. A driver pod whose 
template sets `spec.nodeName` is not gated.

Review Comment:
   **The gate waits only for what `spark-submit` creates for the driver, but 
this line and six other places say it is removed once "the resources the driver 
mounts" exist.** `run()` removes it right after applying the pre-resources, 
`driverKubernetesResources` and the conf ConfigMap (KCA L155-L209). Objects the 
driver mounts that Spark doesn't create aren't waited for: Secrets from 
`spark.kubernetes.driver.secrets.*` (`MountSecretsFeatureStep` only references 
them), `spark.kubernetes.hadoop.configMapName`, 
`spark.kubernetes.kerberos.krb5.configMapName`, and the volumes of a pod 
template. If one of those is created later, e.g. by another controller, the 
kubelet still reports `FailedMount` with the gate on, which these docs say 
can't happen.
   
   The behavior is fine; only the wording promises more. Could we say "once the 
resources `spark-submit` creates for the driver, such as its config map, exist" 
here, at L1345-L1348 and L2316, in the `Config.scala` doc (L678-L681), the 
migration guide, `Constants.scala` L95 and KCA L202? The PR title has the same 
wording.



##########
docs/running-on-kubernetes.md:
##########
@@ -2276,13 +2293,30 @@ See the below table for the full list of pod 
specifications that will be overwri
     Additional node selectors will be added from the spark configuration to 
both executor pods.
   </td>
 </tr>
+<tr>
+  <td>nodeName</td>
+  <td>Kept as the template sets it</td>
+  <td>
+    A driver pod whose template sets <code>nodeName</code> is created without 
the scheduling gate described in
+    <code>spark.kubernetes.submission.useDriverSchedulingGate</code>, because 
Kubernetes rejects a pod with both.
+    The kubelet can therefore try to mount the driver's resources before they 
exist.
+  </td>
+</tr>
 <tr>
   <td>restartPolicy</td>
   <td><code>"never"</code></td>
   <td>
     Spark assumes that both drivers and executors never restart.
   </td>
 </tr>
+<tr>
+  <td>schedulingGates</td>
+  <td>Adds <code>spark.apache.org/driver-resources</code> to driver pods</td>

Review Comment:
   **A few smaller doc points around the gate:**
   
   - This row says Spark adds the gate to driver pods unconditionally, and the 
config doc (`Config.scala` L678-L681 and L1345 here) says "If true, ... created 
with a scheduling gate" without the `nodeName` exception. "unless the pod sets 
`nodeName` or `spark.kubernetes.submission.useDriverSchedulingGate` is `false`" 
would cover both.
   - The `nodeName` row (L2297) sits after `nodeSelector` (L2290) in an 
otherwise alphabetical table. I asked for this row in round 2, when `nodeName` 
was still rewritten, so this is only about the order.
   - L58 still suggests `kubectl auth can-i <list|create|edit|delete> pods`, 
and `edit` isn't an RBAC verb, so the documented check doesn't show the new 
requirement, while the error log at KCA L212 asks for `kubectl auth can-i patch 
pod`. `<list|create|patch|delete>` would line them up.
   - L61, L2300 and the migration guide say a driver pod "whose template sets" 
`nodeName` is not gated, but KCA L141-L142 check the resolved pod, which 
includes user feature steps from `spark.kubernetes.driver.pod.featureSteps`. "A 
driver pod that sets `spec.nodeName`" would be accurate.



##########
resource-managers/kubernetes/core/src/main/scala/org/apache/spark/deploy/k8s/submit/KubernetesClientApplication.scala:
##########
@@ -177,10 +193,30 @@ private[spark] class Client(
       kubernetesClient.resourceList(otherKubernetesResources: 
_*).forceConflicts().serverSideApply()
     } catch {
       case NonFatal(e) =>
-        kubernetesClient.pods().resource(createdDriverPod).delete()
+        Utils.tryLogNonFatalError {
+          kubernetesClient.pods().resource(createdDriverPod).delete()
+        }
         throw e
     }
 
+    // Every resource the driver mounts exists now, so let the scheduler place 
the pod. The
+    // strategic merge patch names the gate, so it is idempotent and leaves 
other gates alone.
+    if (gateDriverPod) {
+      try {
+        kubernetesClient.pods().resource(createdDriverPod).patch(
+          PatchContext.of(PatchType.STRATEGIC_MERGE),
+          s"""{"spec":{"schedulingGates":[""" +
+            
s"""{"$$patch":"delete","name":"$DRIVER_RESOURCES_SCHEDULING_GATE"}]}}""")
+      } catch {
+        case NonFatal(e) =>
+          logError("Please check \"kubectl auth can-i patch pod\" first. It 
should be yes.")

Review Comment:
   **Two small diagnostics points about the gate's failure paths:**
   
   - This hint is logged for every `NonFatal` failure of the PATCH, including a 
timeout or a connection failure after fabric8's retries, a 5xx, or a 404 when 
the pod was deleted in the meantime. It matches the existing hints at L160 and 
L174, so this is optional, but logging it only for a 
`KubernetesClientException` with `getCode == 403`, and mentioning 
`spark.kubernetes.submission.useDriverSchedulingGate=false` there, would point 
users to both fixes.
   - L147 appends the gate even if the resolved pod already has one named 
`spark.apache.org/driver-resources` (from a template or a feature step). The 
API server then rejects the duplicate at CREATE (`spec.schedulingGates[1]: 
Duplicate value`), with the `can-i create pod` hint. That is unlikely, but 
removing a gate of the same name before adding ours would make such a pod work, 
since the PATCH then clears it.



##########
resource-managers/kubernetes/core/src/test/scala/org/apache/spark/deploy/k8s/submit/ClientSuite.scala:
##########
@@ -223,12 +277,210 @@ class ClientSuite extends SparkFunSuite with 
BeforeAndAfter {
     val configMap = configMaps.head
     assert(configMap.getMetadata.getName ===
       KubernetesClientUtils.configMapNameDriver)
+    assert(configMap.getMetadata.getName === mountedConfigMapName)
     assert(configMap.getImmutable())
     assert(configMap.getData.containsKey(SPARK_CONF_FILE_NAME))
     
assert(configMap.getData.get(SPARK_CONF_FILE_NAME).contains("conf1key=conf1value"))
     
assert(configMap.getData.get(SPARK_CONF_FILE_NAME).contains("conf2key=conf2value"))
   }
 
+  test("SPARK-38079: the driver pod is created gated, and the gate is removed 
last") {
+    newClient().run()
+
+    val configMapName = KubernetesClientUtils.configMapNameDriver
+    assert(events.toSeq === Seq(
+      "list()", "apply",
+      "create pod",
+      "list()", "apply",
+      s"list(Secret/secret+owner,ConfigMap/$configMapName+owner)", "apply",

Review Comment:
   **These call-time `+owner` markers read suite-level fixtures that `run()` 
mutates and `before` never resets, so the `Secret/secret+owner` part can't fail 
in a full run.** `ADDITIONAL_RESOURCES`, `PRE_RESOURCES` and the two 
`BUILT_KUBERNETES_SPEC*` specs are `val`s, ScalaTest runs the suite on one 
instance in declaration order, and `addOwnerReference` calls 
`setOwnerReferences` on the objects themselves. Once the tests at L256 and L262 
have run, the shared Secret keeps its owner reference. Only the ConfigMap, 
which `run()` builds fresh, is really checked at call time.
   
   - A change at KCA L191-L193 that sets the owner reference on 
`Seq(configMap)` before the apply, but on `driverKubernetesResources` only 
after it, passes all 17 tests in a full run. Production would then create the 
driver Service, the Kerberos and credentials Secrets, OnDemand PVCs and the 
NetworkPolicy without owners. L287, L301 and L394 catch it only when they run 
first, e.g. with `-z`.
   - The other way round, L312 expects an unowned `preCRD` only because L301 is 
declared before the SPARK-37331 test (L484), which mutates the same CRD. 
Declaring it later makes L312 fail.
   
   The swap from [round 
2](https://github.com/apache/spark/pull/58187#discussion_r4300454498) is still 
caught through the ConfigMap, so this is incomplete rather than wrong. Turning 
the four fixtures into `def`s would fix it.



##########
resource-managers/kubernetes/core/src/test/scala/org/apache/spark/deploy/k8s/submit/ClientSuite.scala:
##########
@@ -223,12 +277,210 @@ class ClientSuite extends SparkFunSuite with 
BeforeAndAfter {
     val configMap = configMaps.head
     assert(configMap.getMetadata.getName ===
       KubernetesClientUtils.configMapNameDriver)
+    assert(configMap.getMetadata.getName === mountedConfigMapName)
     assert(configMap.getImmutable())
     assert(configMap.getData.containsKey(SPARK_CONF_FILE_NAME))
     
assert(configMap.getData.get(SPARK_CONF_FILE_NAME).contains("conf1key=conf1value"))
     
assert(configMap.getData.get(SPARK_CONF_FILE_NAME).contains("conf2key=conf2value"))
   }
 
+  test("SPARK-38079: the driver pod is created gated, and the gate is removed 
last") {
+    newClient().run()
+
+    val configMapName = KubernetesClientUtils.configMapNameDriver
+    assert(events.toSeq === Seq(
+      "list()", "apply",
+      "create pod",
+      "list()", "apply",
+      s"list(Secret/secret+owner,ConfigMap/$configMapName+owner)", "apply",
+      "remove gate"))
+    
assert(createdPodArgumentCaptor.getValue.getSpec.getSchedulingGates.asScala.map(_.getName)
 ===
+      Seq(DRIVER_RESOURCES_SCHEDULING_GATE))
+  }
+
+  test("SPARK-38079: pre resources are applied before the gated pod and owned 
after it") {
+    val preResKconf = KubernetesTestConf.createDriverConf(
+      resourceNamePrefix = Some(KUBERNETES_RESOURCE_PREFIX))
+    when(driverBuilder.buildFromFeatures(preResKconf, kubernetesClient))
+      .thenReturn(BUILT_KUBERNETES_SPEC_WITH_PRERES)
+    kconf = preResKconf
+
+    newClient().run()
+
+    val configMapName = KubernetesClientUtils.configMapNameDriver
+    assert(events.toSeq === Seq(
+      "list(CustomResourceDefinition/preCRD)", "apply",
+      "create pod",
+      "list(CustomResourceDefinition/preCRD+owner)", "apply",
+      s"list(Secret/secret+owner,ConfigMap/$configMapName+owner)", "apply",
+      "remove gate"))
+  }
+
+  test("SPARK-38079: the gate is removed by name with an idempotent strategic 
merge patch") {
+    newClient().run()
+
+    verify(namedPods).patch(
+      ArgumentMatchers.any[PatchContext](), ArgumentMatchers.any[String]())
+    assert(patchContextCaptor.getValue.getPatchType === 
PatchType.STRATEGIC_MERGE)
+    assert(patchBodyCaptor.getValue ===
+      s"""{"spec":{"schedulingGates":[{"$$patch":"delete",""" +
+        s""""name":"$DRIVER_RESOURCES_SCHEDULING_GATE"}]}}""")
+  }
+
+  test("SPARK-38079: gates already on the pod template are kept next to the 
Spark gate") {
+    val templateGate = "example.com/keep-me"
+    val podWithGate = new PodBuilder(BUILT_DRIVER_POD)
+      .editSpec()
+        .addNewSchedulingGate(templateGate)
+        .endSpec()
+      .build()
+    when(driverBuilder.buildFromFeatures(kconf, kubernetesClient)).thenReturn(
+      KubernetesDriverSpec(
+        SparkPod(podWithGate, BUILT_DRIVER_CONTAINER),
+        Nil,
+        ADDITIONAL_RESOURCES,
+        RESOLVED_JAVA_OPTIONS))
+
+    newClient().run()
+
+    
assert(createdPodArgumentCaptor.getValue.getSpec.getSchedulingGates.asScala.map(_.getName)
 ===
+      Seq(templateGate, DRIVER_RESOURCES_SCHEDULING_GATE))
+    assert(!patchBodyCaptor.getValue.contains(templateGate))
+  }
+
+  test("SPARK-38079: the gate patch leaves a gate it does not name in place") {
+    // A fake API server that applies the patch body: only the named gate may 
disappear.
+    val templateGate = "example.com/keep-me"
+    val podWithGate = new PodBuilder(BUILT_DRIVER_POD)
+      .editSpec()
+        .addNewSchedulingGate(templateGate)
+        .endSpec()
+      .build()
+    when(driverBuilder.buildFromFeatures(kconf, kubernetesClient)).thenReturn(
+      KubernetesDriverSpec(
+        SparkPod(podWithGate, BUILT_DRIVER_CONTAINER),
+        Nil,
+        ADDITIONAL_RESOURCES,
+        RESOLVED_JAVA_OPTIONS))
+
+    newClient().run()
+
+    val patch = new com.fasterxml.jackson.databind.ObjectMapper()
+      .readTree(patchBodyCaptor.getValue)
+    val ops = patch.at("/spec/schedulingGates").elements().asScala.toSeq
+    assert(ops.size === 1, "the patch must name exactly one gate")
+    assert(ops.head.get("$patch").asText === "delete")
+    assert(ops.head.get("name").asText === DRIVER_RESOURCES_SCHEDULING_GATE)
+    val remaining = 
createdPodArgumentCaptor.getValue.getSpec.getSchedulingGates.asScala
+      .map(_.getName).filterNot(_ == ops.head.get("name").asText)
+    assert(remaining === Seq(templateGate))
+  }
+
+  test("SPARK-38079: disabling the scheduling gate keeps the previous flow and 
needs no patch") {
+    val sparkConf = new SparkConf(false)
+      .set(Config.USE_DRIVER_SCHEDULING_GATE, false)
+    kconf = KubernetesTestConf.createDriverConf(
+      sparkConf = sparkConf, resourceNamePrefix = 
Some(KUBERNETES_RESOURCE_PREFIX))
+    when(driverBuilder.buildFromFeatures(kconf, 
kubernetesClient)).thenReturn(BUILT_KUBERNETES_SPEC)
+
+    newClient().run()
+
+    
assert(createdPodArgumentCaptor.getValue.getSpec.getSchedulingGates.isEmpty)

Review Comment:
   **The tests pin only the default path: no test compares the whole pod 
created without the gate, and none checks that the gate is removed when 
`waitAppCompletion` is `false`.** On master, the `before` stub 
`resource(fullExpectedPod())` returned `null` for any other pod, so every test 
pinned the created pod. Now `resource(createdPodArgumentCaptor.capture())` 
(L218) matches any pod, `fullExpectedPod()` always carries the gate (L113), and 
both full-pod checks (L258, L574) run with the gate on. So these changes pass 
all 17 tests:
   
   - `else resolvedDriverSpec.pod.pod` at KCA L151, which drops the Spark 
container, the conf volume and `SPARK_CONF_DIR` from every opt-out or 
`nodeName` submission;
   - an `else` branch that clears the template's own gates;
   - moving the gate block (KCA L204-L218) into the `if 
(conf.get(WAIT_FOR_APP_COMPLETION))` branch. The only test with `false`, 
SPARK-42813 (L602), checks only the log, and kubeflow/spark-operator always 
submits with `waitAppCompletion=false` ([submission.go 
L279-L285](https://github.com/kubeflow/spark-operator/blob/v2.5.2/internal/controller/sparkapplication/submission.go#L279-L285)),
 so its drivers would stay gated.
   
   This test also doesn't check the call sequence that its name promises 
("keeps the previous flow"). Could we add a `gated: Boolean = true` parameter 
to `fullExpectedPod` and assert `=== fullExpectedPod(gated = false)` here, the 
same with `nodeName` at L394 and with a template gate that must survive, reuse 
the L414-L419 sequence here, and assert `events.last === "remove gate"` in 
SPARK-42813?



##########
resource-managers/kubernetes/core/src/main/scala/org/apache/spark/deploy/k8s/submit/KubernetesClientApplication.scala:
##########
@@ -151,7 +167,7 @@ private[spark] class Client(
     var createdDriverPod: Pod = null
     try {
       createdDriverPod =
-        
kubernetesClient.pods().inNamespace(conf.namespace).resource(resolvedDriverPod).create()
+        
kubernetesClient.pods().inNamespace(conf.namespace).resource(podToCreate).create()

Review Comment:
   **If `spark-submit` stops between this CREATE and a successful gate PATCH, 
the driver pod stays `SchedulingGated` with nothing to expire it.** For 
example, `spark-submit` is killed (SIGKILL, OOM) after the post-resource apply, 
or the API server becomes unreachable there and both the PATCH and the guarded 
DELETE at L213-L215 fail. The pod is never bound, so it has no `startTime` and 
`activeDeadlineSeconds` never applies; pod GC and the scheduler leave it alone 
too.
   
   We settled the shutdown hook in round 1, and for most of this window master 
leaves a stuck pod as well (in `ContainerCreating`). What is new is the last 
request: on master, a driver whose `spark-submit` died after the post-resource 
apply simply ran. Also, `spark-submit --status` shows only `phase: Pending`, 
which looks like a pod waiting for capacity. Could we add a sentence to the 
docs saying that a driver left `SchedulingGated` by an interrupted submission 
can be removed with `spark-submit --kill` or `kubectl delete pod`?



##########
resource-managers/kubernetes/core/src/test/scala/org/apache/spark/deploy/k8s/submit/ClientSuite.scala:
##########
@@ -180,28 +210,52 @@ class ClientSuite extends SparkFunSuite with 
BeforeAndAfter {
     
when(podOperations.inNamespace(kconf.namespace)).thenReturn(podsWithNamespace)
     when(podsWithNamespace.withName(POD_NAME)).thenReturn(namedPods)
 
+    events.clear()
     createdPodArgumentCaptor = ArgumentCaptor.forClass(classOf[Pod])
     createdResourcesArgumentCaptor = 
ArgumentCaptor.forClass(classOf[Array[HasMetadata]])
-    when(podsWithNamespace.resource(fullExpectedPod())).thenReturn(namedPods)
+    patchContextCaptor = ArgumentCaptor.forClass(classOf[PatchContext])
+    patchBodyCaptor = ArgumentCaptor.forClass(classOf[String])
+    
when(podsWithNamespace.resource(createdPodArgumentCaptor.capture())).thenReturn(namedPods)
+    
when(podOperations.resource(ArgumentMatchers.any[Pod]())).thenReturn(namedPods)
     when(resourceList.forceConflicts()).thenReturn(resourceList)
+    when(resourceList.serverSideApply()).thenAnswer(answer {
+      events += "apply"
+      java.util.Collections.emptyList[HasMetadata]()
+    })
+    when(resourceList.delete()).thenAnswer(answer {
+      events += "delete resources"
+      java.util.Collections.emptyList[StatusDetails]()
+    })
     when(namedPods.serverSideApply()).thenReturn(podWithOwnerReference())
-    when(namedPods.create()).thenReturn(podWithOwnerReference())
+    when(namedPods.create()).thenAnswer(answer {
+      events += "create pod"
+      podWithOwnerReference()
+    })
+    when(namedPods.patch(patchContextCaptor.capture(), 
patchBodyCaptor.capture()))
+      .thenAnswer(answer {
+        events += "remove gate"
+        podWithOwnerReference()
+      })
+    when(namedPods.delete()).thenAnswer(answer {
+      events += "delete pod"
+      java.util.Collections.emptyList[StatusDetails]()
+    })
     when(namedPods.watch(loggingPodStatusWatcher)).thenReturn(mock[Watch])
     val sId = submissionId(kconf.namespace, POD_NAME)
     when(loggingPodStatusWatcher.watchOrStop(sId)).thenReturn(true)
-    doReturn(resourceList)
-      .when(kubernetesClient)
-      .resourceList(createdResourcesArgumentCaptor.capture(): _*)
+    resourceListFor = _ => resourceList
+    doAnswer(new Answer[RESOURCE_LIST] {

Review Comment:
   **This `doAnswer` replaced the only use of the `doReturn` helper at L48, 
which is now unused. A few more leftovers and copies could go too:**
   
   - the `keyToPaths` parameter of `podWithOwnerReference` (L117), whose only 
non-default caller, in the `SPARK_CONF_DIR` test, was removed;
   - the `s` prefix at KCA L208, which has nothing to interpolate;
   - the three `KubernetesDriverSpec(SparkPod(<pod>, BUILT_DRIVER_CONTAINER), 
Nil, ADDITIONAL_RESOURCES, RESOLVED_JAVA_OPTIONS)` copies (L338, L360, L401), 
which are `BUILT_KUBERNETES_SPEC.copy(pod = SparkPod(<pod>, 
BUILT_DRIVER_CONTAINER))`;
   - the template-gate setup (L331-L342 and L353-L364) and the failing 
post-resource setup (L439-L444 and L459-L464), each copied verbatim;
   - `preResKconf` at L302-L306, which has the same arguments as `kconf` in 
`before`;
   - the `never()` checks at L389-L390, L412-L413 and L432-L434, which now 
repeat what `events` already asserts (I asked for the delete one at L432 in 
round 2, before `events` existed).



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