Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -576,6 +576,19 @@ private[spark] object Config extends Logging {
.intConf
.createOptional

val KUBERNETES_EXECUTOR_ENABLE_INFORMER =

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

4. The three new user-facing configs are undocumented

The three new user-facing configs (spark.kubernetes.executor.enableInformer,
listerPollingInterval, informerResyncInterval) have no entries anywhere under docs/, while
the closest precedent spark.kubernetes.executor.apiPollingInterval is documented in the config
table in docs/running-on-kubernetes.md. The mutual exclusion with the two legacy switches
currently lives only in the config doc string, so users can't discover the switch or the
migration notes from the docs; it's also worth noting there that the informer path requires
both list and watch permissions on pods. Could you add the three entries to
running-on-kubernetes.md?

ConfigBuilder("spark.kubernetes.executor.enableInformer")
.doc("If true, use a shared Kubernetes informer (list + watch) to track executor pod " +
"state, driven by ExecutorPodsInformerSnapshotSource (event-driven) and " +
"ExecutorPodsListerSnapshotSource (periodic refresh of the informer cache). If " +
"false (default), use the legacy path backed by ExecutorPodsWatchSnapshotSource and " +
"ExecutorPodsPollingSnapshotSource. The two modes are mutually exclusive; " +
"`spark.kubernetes.executor.enableApiWatcher` and " +
"`spark.kubernetes.executor.enableApiPolling` only apply when this is false.")
.version("4.4.0")
.booleanConf
.createWithDefault(false)

val KUBERNETES_ALLOCATION_BATCH_SIZE =
ConfigBuilder("spark.kubernetes.allocation.batch.size")
.doc("Number of pods to launch at once in each round of executor allocation.")
Expand Down Expand Up @@ -668,6 +681,24 @@ private[spark] object Config extends Logging {
.booleanConf
.createWithDefault(true)

val KUBERNETES_EXECUTOR_LISTER_POLLING_INTERVAL =

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

10. The new config toggle lands ~90 lines away from its family, and this PR splits the legacy family too

KUBERNETES_EXECUTOR_ENABLE_INFORMER lands at line 579 (between POD_DELETION_COST and
ALLOCATION_BATCH_SIZE), while its semantic siblings ENABLE_API_POLLING/ENABLE_API_WATCHER
and the two new interval configs sit ~90 lines further down. The two new intervals are also
inserted between the legacy toggles and API_POLLING_INTERVAL, so this PR splits the legacy
family that used to be contiguous as well. Moving the toggle down next to the two intervals,
and placing the new intervals after API_POLLING_INTERVAL, would keep both families together.

ConfigBuilder("spark.kubernetes.executor.listerPollingInterval")
.doc("Interval between polls against the Kubernetes informer cache to inspect the " +
"state of executors.")
.version("4.4.0")
.timeConf(TimeUnit.MILLISECONDS)
.checkValue(interval => interval > 0,
"Informer lister polling interval must be a positive time value.")
.createWithDefaultString("30s")

val KUBERNETES_EXECUTOR_INFORMER_RESYNC_INTERVAL =
ConfigBuilder("spark.kubernetes.executor.informerResyncInterval")
.doc("Interval between informer cache resync.")
.version("4.4.0")
.timeConf(TimeUnit.MILLISECONDS)
.checkValue(interval => interval >= 0,
"Informer resync interval must not be a negative time value.")
.createWithDefaultString("0s")

val KUBERNETES_EXECUTOR_API_POLLING_INTERVAL =
ConfigBuilder("spark.kubernetes.executor.apiPollingInterval")
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
/*
* Licensed to the Apache Software Foundation (ASF) under one or more
* contributor license agreements. See the NOTICE file distributed with
* this work for additional information regarding copyright ownership.
* The ASF licenses this file to You under the Apache License, Version 2.0
* (the "License"); you may not use this file except in compliance with
* the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.apache.spark.scheduler.cluster.k8s

import io.fabric8.kubernetes.api.model.Pod
import io.fabric8.kubernetes.client.informers.ResourceEventHandler

import org.apache.spark.internal.Logging
import org.apache.spark.util.Utils

/**
* Publishes executor pod updates to [[ExecutorPodsSnapshotsStore]] using the shared informer
* owned by [[InformerManager]]. Event-driven counterpart of [[ExecutorPodsListerSnapshotSource]],
* which periodically snapshots the same informer's local cache.
*/
class ExecutorPodsInformerSnapshotSource(
snapshotsStore: ExecutorPodsSnapshotsStore,
informerManager: InformerManager)
extends ExecutorPodsSnapshotSource with Logging {

override def start(applicationId: String): Unit = {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

8. start() has no double-start guard, and a misleading INFO fires on every normal startup

Neither new source's start() has the double-start guard the legacy ones have ("Cannot start
the watcher twice." / "Cannot start polling more than once."); starting the informer source
twice would add a second handler and duplicate events, and a second lister start would overwrite
and leak the first pollingFuture. Also, since the informer source runs the informer first and
the lister source then sees isRunning=true, the logInfo("Informer is already running.") in
InformerManager fires on every normal startup and reads like something went wrong. Adding the
require guards and demoting that log to debug would match the existing sources.

informerManager.initInformer(applicationId)
informerManager.getInformer().addEventHandler(new ExecutorPodsInformer())
informerManager.startInformer()
}

override def stop(): Unit = {
Utils.tryLogNonFatalError {
informerManager.stopInformer()
}
}

private class ExecutorPodsInformer extends ResourceEventHandler[Pod] {
override def onAdd(pod: Pod): Unit = {
logDebug(s"Received add executor pod event for pod named ${pod.getMetadata.getName}")
snapshotsStore.updatePod(pod)
}

override def onUpdate(oldPod: Pod, newPod: Pod): Unit = {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

7. resync > 0 replays N snapshots per round (churn only; off by default)

With informerResyncInterval > 0, each resync replays onUpdate for every pod; the handler calls
updatePod unconditionally and ExecutorPodsSnapshot.withUpdate doesn't dedup by
resourceVersion, so N executors produce N snapshot objects per round and both subscribers rescan
everything. It's idempotent (fullSnapshotTs is preserved), so this is churn rather than a
correctness issue, and the default resync=0 avoids it — just noting a driver-side cost
proportional to the executor count when the interval is set low. Comparing resourceVersion
before updatePod in the handler would cap it.

logDebug(s"Received update executor pod event for pod named ${newPod.getMetadata.getName}")
snapshotsStore.updatePod(newPod)
}

override def onDelete(pod: Pod, deletedFinalStateUnknown: Boolean): Unit = {
logDebug(s"Received delete executor pod event for pod named ${pod.getMetadata.getName}")
snapshotsStore.updatePod(pod)
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
/*
* Licensed to the Apache Software Foundation (ASF) under one or more
* contributor license agreements. See the NOTICE file distributed with
* this work for additional information regarding copyright ownership.
* The ASF licenses this file to You under the Apache License, Version 2.0
* (the "License"); you may not use this file except in compliance with
* the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.apache.spark.scheduler.cluster.k8s

import java.util.concurrent.{Future, ScheduledExecutorService, TimeUnit}

import scala.jdk.CollectionConverters._

import io.fabric8.kubernetes.api.model.Pod
import io.fabric8.kubernetes.client.KubernetesClient
import io.fabric8.kubernetes.client.informers.cache.Lister

import org.apache.spark.SparkConf
import org.apache.spark.deploy.k8s.Config.KUBERNETES_EXECUTOR_LISTER_POLLING_INTERVAL
import org.apache.spark.internal.Logging
import org.apache.spark.util.{ThreadUtils, Utils}

/**
* Periodically snapshots the local cache of the shared [[InformerManager]] and replaces the
* contents of the [[ExecutorPodsSnapshotsStore]] with the result. Companion to
* [[ExecutorPodsInformerSnapshotSource]], which pushes updates as informer events arrive.
*/
class ExecutorPodsListerSnapshotSource(
conf: SparkConf,
kubernetesClient: KubernetesClient,
snapshotsStore: ExecutorPodsSnapshotsStore,
informerManager: InformerManager,
pollingExecutor: ScheduledExecutorService)
extends ExecutorPodsSnapshotSource with Logging {

private val pollingInterval = conf.get(KUBERNETES_EXECUTOR_LISTER_POLLING_INTERVAL)

private var pollingFuture: Future[_] = _

override def start(applicationId: String): Unit = {
informerManager.initInformer(applicationId)
informerManager.startInformer()
val lister = new Lister[Pod](
informerManager.getInformer().getIndexer, kubernetesClient.getNamespace)
pollingFuture = pollingExecutor.scheduleWithFixedDelay(
new PollRunnable(lister), pollingInterval, pollingInterval, TimeUnit.MILLISECONDS)
}

override def stop(): Unit = {
if (pollingFuture != null) {
pollingFuture.cancel(true)
pollingFuture = null
}
Utils.tryLogNonFatalError {
informerManager.stopInformer()
}
ThreadUtils.shutdown(pollingExecutor)
}

private class PollRunnable(lister: Lister[Pod]) extends Runnable {
override def run(): Unit = Utils.tryLogNonFatalError {
// The informer is already scoped server-side to app-id + role=executor + non-inactive
// pods, so we can hand its snapshot to the store as-is.
snapshotsStore.replaceSnapshot(lister.list().asScala.toSeq)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

3. The lister poll doesn't check hasSynced(), so an unsynced empty cache would wipe the snapshot store

PollRunnable unconditionally calls replaceSnapshot(lister.list()), but the informer's local
cache is empty until the initial LIST finishes. replaceSnapshot replaces wholesale with a fresh
fullSnapshotTs, so ExecutorPodsLifecycleManager's missing-pod reconcile fires on every poll
and removes every executor registered more than missingPodDetectDelta (30s) ago via
doRemoveExecutor, while the allocator re-requests a full batch seeing zero known executors.
Today the window is masked by the blocking run() (by the time backend.start() returns, the
cache is synced), but it opens the moment start becomes async per my startup comment: any
namespace where the initial sync is slower than listerPollingInterval hits it.

Skipping the round when !informer.hasSynced() fixes it; the "Empty list of pods" test should
then assert no replacement happens while unsynced. Note this check needs the force-retry
exceptionHandler from my exceptionHandler comment as well — a never-synced, dead informer would
otherwise mean polls skipped forever.

}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,7 @@ class ExecutorPodsPollingSnapshotSource(
conf: SparkConf,
kubernetesClient: KubernetesClient,
snapshotsStore: ExecutorPodsSnapshotsStore,
pollingExecutor: ScheduledExecutorService) extends Logging {
pollingExecutor: ScheduledExecutorService) extends ExecutorPodsSnapshotSource with Logging {

private val pollingInterval = conf.get(KUBERNETES_EXECUTOR_API_POLLING_INTERVAL)
private val pollingEnabled = conf.get(KUBERNETES_EXECUTOR_ENABLE_API_POLLING)
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
/*
* Licensed to the Apache Software Foundation (ASF) under one or more
* contributor license agreements. See the NOTICE file distributed with
* this work for additional information regarding copyright ownership.
* The ASF licenses this file to You under the Apache License, Version 2.0
* (the "License"); you may not use this file except in compliance with
* the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.apache.spark.scheduler.cluster.k8s

/**
* Publishes snapshots of the set of executor pods that Kubernetes reports as running for an
* application. Built-in implementations are chosen by
* [[org.apache.spark.deploy.k8s.Config.KUBERNETES_EXECUTOR_ENABLE_INFORMER]].
*/
trait ExecutorPodsSnapshotSource {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

11. The four new types are public with no annotation, inconsistent with adjacent types

ExecutorPodsSnapshotSource, InformerManager, and the two new sources are all public with no
annotation, while the types they sit next to differ: ExecutorPodsSnapshotsStore is
private[spark], and the two legacy sources carry @Stable @DeveloperApi on the classes and
@Since on the methods. The new
trait now sits in the public hierarchy of two @DeveloperAPI classes without being @DeveloperAPI
itself, and InformerManager is pure internal wiring with no need to be public. Marking the
trait @DeveloperAPI and narrowing InformerManager to private[spark] (or the whole group) would
avoid widening the API surface first and paying a breaking change to shrink it later.

def start(applicationId: String): Unit
def stop(): Unit
}
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,7 @@ import org.apache.spark.util.Utils
class ExecutorPodsWatchSnapshotSource(
snapshotsStore: ExecutorPodsSnapshotsStore,
kubernetesClient: KubernetesClient,
conf: SparkConf) extends Logging {
conf: SparkConf) extends ExecutorPodsSnapshotSource with Logging {

private var watchConnection: Closeable = _
private val enableWatching = conf.get(KUBERNETES_EXECUTOR_ENABLE_API_WATCHER)
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
/*
* Licensed to the Apache Software Foundation (ASF) under one or more
* contributor license agreements. See the NOTICE file distributed with
* this work for additional information regarding copyright ownership.
* The ASF licenses this file to You under the Apache License, Version 2.0
* (the "License"); you may not use this file except in compliance with
* the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.apache.spark.scheduler.cluster.k8s

import io.fabric8.kubernetes.api.model.Pod
import io.fabric8.kubernetes.client.KubernetesClient
import io.fabric8.kubernetes.client.informers.SharedIndexInformer

import org.apache.spark.SparkConf
import org.apache.spark.deploy.k8s.Config.KUBERNETES_EXECUTOR_INFORMER_RESYNC_INTERVAL
import org.apache.spark.deploy.k8s.Constants.{SPARK_APP_ID_LABEL, SPARK_EXECUTOR_INACTIVE_LABEL, SPARK_POD_EXECUTOR_ROLE, SPARK_ROLE_LABEL}
import org.apache.spark.internal.Logging
import org.apache.spark.util.Utils

/**
* Owns the shared [[SharedIndexInformer]] used by executor pod snapshot sources when the
* informer-based mode is enabled. The informer is scoped server-side to the current
* application's executor pods that are not marked inactive, matching the filter set used by
* [[ExecutorPodsWatchSnapshotSource]] and [[ExecutorPodsPollingSnapshotSource]].
*/
class InformerManager(kubernetesClient: KubernetesClient, conf: SparkConf)
extends Logging {

private val resyncInterval = conf.get(KUBERNETES_EXECUTOR_INFORMER_RESYNC_INTERVAL)
// VisibleForTesting
private[k8s] var informer: SharedIndexInformer[Pod] = _
private var stopped = false

def initInformer(applicationId: String): Unit = {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

6. initInformer ignores stopped, and the test's intercept scope masks it

After stopInformer(), stopped=true and informer=null, so a later initInformer silently
builds an informer that can never start (blocked by stopped) and is never closed. Production
code doesn't do this today, but the suite's "Calling startInformer after stopInformer should
throw" wraps both initInformer and startInformer in one intercept, so it passes regardless
of which one throws and hides exactly this gap. Could you make initInformer throw when stopped
(or explicitly allow revival), with the test asserting the two steps separately?

if (informer == null) {
logInfo(s"Initializing executor pods informer for application $applicationId")
informer = kubernetesClient.pods()
.withLabel(SPARK_APP_ID_LABEL, applicationId)
.withLabel(SPARK_ROLE_LABEL, SPARK_POD_EXECUTOR_ROLE)
.withoutLabel(SPARK_EXECUTOR_INACTIVE_LABEL, "true")
.runnableInformer(resyncInterval)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

2. No exceptionHandler: startup errors are not retried, and a mid-run informer death is silent

InformerManager sets no exceptionHandler and never consumes stopped(). In fabric8 7.x the
ExceptionHandler decides retry-vs-stop, and the default chooses stop for any error before the
first successful sync (it already logs internally — what's missing is the retry decision), and
for non-GONE WatcherExceptions after startup too. So with the current code, a mid-run informer
death is silent: events stop, the lister keeps replacing snapshots from a cache that no longer
updates, and dead executors wait for RPC timeouts to be noticed. And if start becomes async per
my startup comment without a handler, any startup error turns into a silent background death
that the hasSynced check would then skip past forever; strictly worse than crashing.

Please set exceptionHandler((b, t) => { logError(...); true }) in initInformer (it can only
be set before start) to log and force a retry, or consume the exceptional completion of
stopped() and fail the driver.

}
}

def getInformer(): SharedIndexInformer[Pod] = {
if (informer == null) {
throw new IllegalStateException(
"Informer has not been initialized. Call initInformer() first.")
}
informer
}

def startInformer(): Unit = {
if (informer == null) {
throw new IllegalStateException(
"Informer has not been initialized. Call initInformer() first.")
}
if (stopped) {
throw new IllegalStateException("Cannot run informer after stopInformer() has been called.")
}
if (!informer.isRunning) {
informer.run()

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

1. startInformer() runs the blocking, unbounded run() on the SparkContext creation thread

InformerManager.startInformer() calls informer.run() directly; in fabric8 7.x run() blocks
until the initial LIST completes and the watch is established, and this runs on the SparkContext
creation thread, whereas the legacy watch/polling sources start fully asynchronously. From the
v7.8.0 Reflector source, the default exception handler declines to retry any error before the
first successful sync, so an apiserver hiccup at startup (throttling, transient error) makes
run() throw on the calling thread and SparkContext creation fail outright; a slow-but-progressing
initial LIST (large namespace) blocks startup with no timeout and no log. A cold start lists ~0
matching pods, so manual verification wouldn't show it.

Please switch to start() (which doesn't block the caller), but this must come with an
exceptionHandler that forces retries (see my other comment) — otherwise you've only traded a
startup crash for an informer that dies silently in the background. The lister poll also needs
a hasSynced() check.

} else {
logInfo("Informer is already running.")
}
}

def stopInformer(): Unit = {
if (informer != null) {
Utils.tryLogNonFatalError {
informer.close()
}
informer = null
stopped = true
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -137,15 +137,12 @@ private[spark] class KubernetesClusterManager extends ExternalClusterManager wit
val executorPodsAllocator = makeExecutorPodsAllocator(
sc, kubernetesClient, snapshotsStore, Some(executorPodsLifecycleManager))

val podsWatchEventSource = new ExecutorPodsWatchSnapshotSource(
snapshotsStore,
kubernetesClient,
sc.conf)

val eventsPollingExecutor = ThreadUtils.newDaemonSingleThreadScheduledExecutor(
"kubernetes-executor-pod-polling-sync")
val podsPollingEventSource = new ExecutorPodsPollingSnapshotSource(
sc.conf, kubernetesClient, snapshotsStore, eventsPollingExecutor)
val snapshotSources = {
val sources = makeSnapshotSources(sc.conf, kubernetesClient, snapshotsStore)
logInfo(s"Executor pods snapshot sources: " +
sources.map(_.getClass.getSimpleName).mkString(", "))
sources
}

new KubernetesClusterSchedulerBackend(
scheduler.asInstanceOf[TaskSchedulerImpl],
Expand All @@ -155,8 +152,7 @@ private[spark] class KubernetesClusterManager extends ExternalClusterManager wit
snapshotsStore,
executorPodsAllocator,
executorPodsLifecycleManager,
podsWatchEventSource,
podsPollingEventSource)
snapshotSources)
}

private[k8s] def makeExecutorPodsAllocator(
Expand Down Expand Up @@ -204,6 +200,28 @@ private[spark] class KubernetesClusterManager extends ExternalClusterManager wit
allocatorInstance
}

private def makeSnapshotSources(

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

5. The mode selection in makeSnapshotSources has no test, and the method is private

makeSnapshotSources is private, while the sibling makeExecutorPodsAllocator in the same
file is private[k8s] for testability, and KubernetesClusterManagerSuite never references it
or enableInformer. So the core wiring of this PR (which pair of sources the flag selects) has
no coverage, and the informer-mode sources are never run through
KubernetesClusterSchedulerBackend's start/stop; a wrong branch or a missing listerExecutor
would pass every existing test. Could you make it private[k8s] and add a test asserting the
selected source types per flag value?

conf: SparkConf,
kubernetesClient: KubernetesClient,
snapshotsStore: ExecutorPodsSnapshotsStore): Seq[ExecutorPodsSnapshotSource] = {
if (conf.get(KUBERNETES_EXECUTOR_ENABLE_INFORMER)) {
val informerManager = new InformerManager(kubernetesClient, conf)
val listerExecutor = ThreadUtils.newDaemonSingleThreadScheduledExecutor(
"kubernetes-executor-pod-lister-sync")
Seq(
new ExecutorPodsInformerSnapshotSource(snapshotsStore, informerManager),
new ExecutorPodsListerSnapshotSource(
conf, kubernetesClient, snapshotsStore, informerManager, listerExecutor))
} else {
val eventsPollingExecutor = ThreadUtils.newDaemonSingleThreadScheduledExecutor(
"kubernetes-executor-pod-polling-sync")
Seq(
new ExecutorPodsWatchSnapshotSource(snapshotsStore, kubernetesClient, conf),
new ExecutorPodsPollingSnapshotSource(
conf, kubernetesClient, snapshotsStore, eventsPollingExecutor))
}
}

override def initialize(scheduler: TaskScheduler, backend: SchedulerBackend): Unit = {
scheduler.asInstanceOf[TaskSchedulerImpl].initialize(backend)
}
Expand Down
Loading