This is an automated email from the ASF dual-hosted git repository.
potiuk pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/airflow.git
The following commit(s) were added to refs/heads/main by this push:
new 703a0570c84 Add opt-in concurrent pod creation to KubernetesExecutor
(#68480)
703a0570c84 is described below
commit 703a0570c845df8d99122d643ee9cf600562fe54
Author: Stefan Wang <[email protected]>
AuthorDate: Wed Jul 29 02:58:46 2026 -0700
Add opt-in concurrent pod creation to KubernetesExecutor (#68480)
---
providers/cncf/kubernetes/provider.yaml | 25 +
.../kubernetes/executors/kubernetes_executor.py | 272 ++++++++---
.../executors/kubernetes_executor_utils.py | 111 ++++-
.../providers/cncf/kubernetes/get_provider_info.py | 14 +
.../providers/cncf/kubernetes/hooks/kubernetes.py | 60 +--
.../providers/cncf/kubernetes/kube_client.py | 102 ++++
.../providers/cncf/kubernetes/kube_config.py | 7 +
.../cncf/kubernetes/kubernetes_helper_functions.py | 11 +-
.../executors/test_kubernetes_executor.py | 513 ++++++++++++++++++++-
.../tests/unit/cncf/kubernetes/test_kube_client.py | 39 ++
.../observability/metrics/metrics_template.yaml | 13 +
11 files changed, 1032 insertions(+), 135 deletions(-)
diff --git a/providers/cncf/kubernetes/provider.yaml
b/providers/cncf/kubernetes/provider.yaml
index b9905d301ec..a6ba5b42ede 100644
--- a/providers/cncf/kubernetes/provider.yaml
+++ b/providers/cncf/kubernetes/provider.yaml
@@ -364,6 +364,31 @@ config:
type: string
example: ~
default: "1"
+ async_pod_creation:
+ description: |
+ Create worker pods concurrently within each scheduler loop instead of
+ sequentially. When enabled, the ``worker_pods_creation_batch_size``
pods
+ dequeued per loop are submitted to the Kubernetes API concurrently
(bounded
+ by ``pod_creation_max_concurrency``) using the asynchronous
Kubernetes client.
+ This reduces the time the scheduler loop spends blocked on pod
creation when
+ per-call latency is high (network round-trip, admission webhooks).
Pod
+ templates are still built synchronously; only the create API calls
are
+ parallelized.
+ version_added: 10.20.0
+ type: boolean
+ example: ~
+ default: "False"
+ pod_creation_max_concurrency:
+ description: |
+ Maximum number of concurrent pod-creation API calls when
``async_pod_creation``
+ is enabled. Bounds the burst of simultaneous requests to the
Kubernetes API
+ server (and admission webhooks) to avoid tripping API
priority-and-fairness or
+ rate limits. Has no effect when ``async_pod_creation`` is False.
When set to 0
+ the limit falls back to ``worker_pods_creation_batch_size``.
+ version_added: 10.20.0
+ type: integer
+ example: "16"
+ default: "0"
multi_namespace_mode:
description: |
Allows users to launch pods in multiple namespaces.
diff --git
a/providers/cncf/kubernetes/src/airflow/providers/cncf/kubernetes/executors/kubernetes_executor.py
b/providers/cncf/kubernetes/src/airflow/providers/cncf/kubernetes/executors/kubernetes_executor.py
index 8aaa430b921..b75815df2fd 100644
---
a/providers/cncf/kubernetes/src/airflow/providers/cncf/kubernetes/executors/kubernetes_executor.py
+++
b/providers/cncf/kubernetes/src/airflow/providers/cncf/kubernetes/executors/kubernetes_executor.py
@@ -34,6 +34,7 @@ from collections.abc import Iterable
from contextlib import suppress
from dataclasses import dataclass
from datetime import datetime, timedelta
+from http import HTTPStatus
from itertools import chain
from queue import Empty, Queue
from typing import TYPE_CHECKING, Any
@@ -53,7 +54,10 @@ from
airflow.providers.cncf.kubernetes.executors.kubernetes_executor_types impor
KubernetesResults,
)
from airflow.providers.cncf.kubernetes.kube_config import KubeConfig
-from airflow.providers.cncf.kubernetes.kubernetes_helper_functions import
annotations_to_key
+from airflow.providers.cncf.kubernetes.kubernetes_helper_functions import (
+ TRANSIENT_CONNECTION_ERRORS,
+ annotations_to_key,
+)
from airflow.providers.cncf.kubernetes.pod_generator import PodGenerator
from airflow.providers.cncf.kubernetes.version_compat import AIRFLOW_V_3_0_PLUS
from airflow.providers.common.compat.sdk import Stats, conf
@@ -457,92 +461,210 @@ class KubernetesExecutor(BaseExecutor):
last_resource_version[ns] or
resource_instance.resource_version[ns]
)
- from kubernetes.client.rest import ApiException
-
if self.create_pods_after and self.create_pods_after > datetime.now():
self.log.warning("Skipping pod creation due to kubernetes rate
limit")
return
self.create_pods_after = None
+ if self.kube_config.async_pod_creation:
+ self._create_pods_concurrently()
+ else:
+ self._create_pods_sequentially()
+
+ def _create_pods_sequentially(self) -> None:
+ """Dequeue a batch and create worker pods one at a time (default
behavior)."""
+ from kubernetes.client.rest import ApiException
+
+ if TYPE_CHECKING:
+ assert self.kube_scheduler
+ assert self.task_queue
+ created: int = 0
+ start: float = time.monotonic()
with contextlib.suppress(Empty):
for _ in range(self.kube_config.worker_pods_creation_batch_size):
- task = self.task_queue.get_nowait()
-
+ task: KubernetesJob = self.task_queue.get_nowait()
+ created += 1
try:
- key = task.key
self.kube_scheduler.run_next(task)
- self.task_publish_retries.pop(key, None)
- except PodReconciliationError as e:
- self.log.exception(
- "Pod reconciliation failed, likely due to kubernetes
library upgrade. "
- "Try clearing the task to re-run.",
- )
- self.fail(task[0], e)
- except ApiException as e:
- try:
- if e.body:
- body = json.loads(e.body)
- else:
- # If no body content, use reason as the message
- body = {"message": e.reason}
- except (json.JSONDecodeError, ValueError, TypeError):
- # If the body is a string (e.g., in a 429 error), it
can't be parsed as JSON.
- # Use the body directly as the message instead.
- body = {"message": e.body}
-
- headers = e.headers or {}
- retries = self.task_publish_retries[key]
- # In case of exceeded quota or conflict errors, requeue
the task as per the task_publish_max_retries
- # In case of a rate limit, wait and do not create new pods
for "Retry-After" seconds
- can_retry_publish = (
- self.task_publish_max_retries == -1 or retries <
self.task_publish_max_retries
- )
- message = body.get("message", "")
- if (
- (str(e.status) == "403" and "exceeded quota" in
message)
- or (str(e.status) == "409" and "object has been
modified" in message)
- or (str(e.status) == "410" and "too old resource
version" in message)
- or str(e.status) == "500"
- or str(e.status) == "429"
- ) and can_retry_publish:
- self.log.warning(
- "[Try %s of %s] Kube ApiException for Task: (%s).
Reason: %r. Message: %s",
- self.task_publish_retries[key] + 1,
- self.task_publish_max_retries,
- key,
- e.reason,
- message,
- )
-
- self.task_queue.put(task)
- self.task_publish_retries[key] = retries + 1
-
- if str(e.status) == "429":
- self.create_pods_after = datetime.now() +
timedelta(
- seconds=int(headers.get("Retry-After", "0"))
- )
- self.log.warning(
- "Got rate limit from k8s api, skipping pod
creation until %s",
- self.create_pods_after,
- )
- # stop pod creation to stop api requests
- break
- else:
- self.log.error("Pod creation failed with reason %r.
Failing task", e.reason)
- key = task.key
- self.fail(key, e)
- self.task_publish_retries.pop(key, None)
- except PodMutationHookException as e:
- key = task.key
- self.log.error(
- "Pod Mutation Hook failed for the task %s. Failing
task. Details: %s",
- key,
- e.__cause__,
- )
- self.fail(key, e)
+ self.task_publish_retries.pop(task.key, None)
+ except (
+ PodReconciliationError,
+ ApiException,
+ PodMutationHookException,
+ *TRANSIENT_CONNECTION_ERRORS,
+ ) as e:
+ if self._handle_pod_publish_error(task, e):
+ # Rate limited: stop creating further pods this loop.
+ break
finally:
self.task_queue.task_done()
+ if created:
+ self._record_pod_creation_batch(created, time.monotonic() - start)
+
+ def _create_pods_concurrently(self) -> None:
+ """
+ Dequeue a batch and create worker pods concurrently via the async
client.
+
+ The whole batch is in flight before any response returns, so (unlike
the sequential
+ path) a mid-batch 429 cannot stop the burst — it is bounded only by
+ ``pod_creation_max_concurrency``, and a 429 still suppresses the next
loop via
+ ``create_pods_after``.
+ """
+ if TYPE_CHECKING:
+ assert self.kube_scheduler
+ assert self.task_queue
+ jobs: list[KubernetesJob] = []
+ with contextlib.suppress(Empty):
+ for _ in range(self.kube_config.worker_pods_creation_batch_size):
+ jobs.append(self.task_queue.get_nowait())
+ if not jobs:
+ return
+ start: float = time.monotonic()
+ try:
+ results: list[tuple[KubernetesJob, Exception | None]] =
self.kube_scheduler.run_next_batch(jobs)
+ except Exception:
+ # Catastrophic failure (e.g. async client setup): keep queue
accounting correct
+ # by marking every dequeued task done before surfacing the error.
+ for _ in jobs:
+ self.task_queue.task_done()
+ raise
+ self._record_pod_creation_batch(len(jobs), time.monotonic() - start)
+ for task, error in results:
+ try:
+ if error is None:
+ self.task_publish_retries.pop(task.key, None)
+ else:
+ self._handle_pod_publish_error(task, error)
+ finally:
+ self.task_queue.task_done()
+
+ def _record_pod_creation_batch(self, count: int, duration_seconds: float)
-> None:
+ """Emit per-loop pod-creation batch metrics (duration, size); both
paths use the same names."""
+ Stats.timing("kubernetes_executor.pod_creation_batch_duration",
timedelta(seconds=duration_seconds))
+ Stats.gauge("kubernetes_executor.pod_creation_batch_size", count)
+
+ def _handle_pod_publish_error(self, task: KubernetesJob, e: Exception) ->
bool:
+ """
+ Handle a build/create failure for a worker pod; shared by both
creation paths.
+
+ The sync and async clients' ApiExceptions expose the same fields, so
they are handled
+ uniformly. Returns True if pod creation should stop for the rest of
this scheduler loop
+ (rate limit), else False.
+ """
+ from kubernetes.client.rest import ApiException
+ from kubernetes_asyncio.client.exceptions import ApiException as
AsyncApiException
+
+ if TYPE_CHECKING:
+ assert self.task_queue
+ key: TaskInstanceKey = task.key
+ if isinstance(e, PodReconciliationError):
+ self.log.exception(
+ "Pod reconciliation failed, likely due to kubernetes library
upgrade. "
+ "Try clearing the task to re-run.",
+ )
+ self.fail(key, e)
+ return False
+ if isinstance(e, PodMutationHookException):
+ self.log.error(
+ "Pod Mutation Hook failed for the task %s. Failing task.
Details: %s",
+ key,
+ e.__cause__,
+ )
+ self.fail(key, e)
+ return False
+ if isinstance(e, (ApiException, AsyncApiException)):
+ body: dict[str, Any]
+ try:
+ if e.body:
+ body = json.loads(e.body)
+ else:
+ # If no body content, use reason as the message
+ body = {"message": e.reason}
+ except (json.JSONDecodeError, ValueError, TypeError):
+ # If the body is a string (e.g., in a 429 error), it can't be
parsed as JSON.
+ # Use the body directly as the message instead.
+ body = {"message": e.body}
+
+ headers = e.headers or {}
+ retries: int = self.task_publish_retries[key]
+ # Requeue transient failures (exceeded-quota / stale-version
conflicts and the api
+ # server's 429 / 5xx) up to task_publish_max_retries; anything
else fails immediately.
+ can_retry_publish = self.task_publish_max_retries == -1 or retries
< self.task_publish_max_retries
+ message: str = body.get("message", "")
+ # Retry-After (seconds): the apiserver sets it on 429 (APF
throttling) and on 503 while
+ # shutting down. Ignore a malformed value rather than crashing the
scheduler loop.
+ retry_after = headers.get("Retry-After")
+ try:
+ retry_after_seconds: int | None = int(retry_after) if
retry_after is not None else None
+ except (TypeError, ValueError):
+ retry_after_seconds = None
+ transient_5xx = (
+ HTTPStatus.INTERNAL_SERVER_ERROR,
+ HTTPStatus.BAD_GATEWAY,
+ HTTPStatus.SERVICE_UNAVAILABLE,
+ HTTPStatus.GATEWAY_TIMEOUT,
+ )
+ if (
+ (e.status == HTTPStatus.FORBIDDEN and "exceeded quota" in
message)
+ or (e.status == HTTPStatus.CONFLICT and "object has been
modified" in message)
+ or (e.status == HTTPStatus.GONE and "too old resource version"
in message)
+ or e.status == HTTPStatus.TOO_MANY_REQUESTS
+ or e.status in transient_5xx
+ ) and can_retry_publish:
+ self.log.warning(
+ "[Try %s of %s] Kube ApiException for Task: (%s). Reason:
%r. Message: %s",
+ self.task_publish_retries[key] + 1,
+ self.task_publish_max_retries,
+ key,
+ e.reason,
+ message,
+ )
+
+ self.task_queue.put(task)
+ self.task_publish_retries[key] = retries + 1
+
+ # Pause the loop until Retry-After when the server told us to
back off (429, or 503
+ # on apiserver shutdown); other transient 5xx retry on the
next loop.
+ if e.status == HTTPStatus.TOO_MANY_REQUESTS or
retry_after_seconds is not None:
+ self.create_pods_after = datetime.now() +
timedelta(seconds=retry_after_seconds or 0)
+ self.log.warning(
+ "Backing off pod creation until %s after status %s
from k8s api",
+ self.create_pods_after,
+ e.status,
+ )
+ # stop pod creation to stop api requests
+ return True
+ else:
+ self.log.error("Pod creation failed with reason %r. Failing
task", e.reason)
+ self.fail(key, e)
+ self.task_publish_retries.pop(key, None)
+ return False
+ if isinstance(e, TRANSIENT_CONNECTION_ERRORS):
+ # Connection-level failure talking to the api server (reset / DNS
blip / read timeout) —
+ # the category generic_api_retry treats as transient. Re-queue
rather than fail; the
+ # create may not have reached the server, so a later loop retries.
+ retries = self.task_publish_retries[key]
+ if self.task_publish_max_retries == -1 or retries <
self.task_publish_max_retries:
+ self.log.warning(
+ "[Try %s of %s] Transient connection error creating pod
for Task %s: %r",
+ retries + 1,
+ self.task_publish_max_retries,
+ key,
+ e,
+ )
+ self.task_queue.put(task)
+ self.task_publish_retries[key] = retries + 1
+ else:
+ self.log.error(
+ "Connection error creating pod, retries exhausted. Failing
task %s: %r", key, e
+ )
+ self.fail(key, e)
+ self.task_publish_retries.pop(key, None)
+ return False
+ # Unknown exception type: fail the task rather than silently dropping
it.
+ self.fail(key, e)
+ return False
@provide_session
def _change_state(
diff --git
a/providers/cncf/kubernetes/src/airflow/providers/cncf/kubernetes/executors/kubernetes_executor_utils.py
b/providers/cncf/kubernetes/src/airflow/providers/cncf/kubernetes/executors/kubernetes_executor_utils.py
index 2c4bd306e52..0bf1ed8b23e 100644
---
a/providers/cncf/kubernetes/src/airflow/providers/cncf/kubernetes/executors/kubernetes_executor_utils.py
+++
b/providers/cncf/kubernetes/src/airflow/providers/cncf/kubernetes/executors/kubernetes_executor_utils.py
@@ -16,6 +16,7 @@
# under the License.
from __future__ import annotations
+import asyncio
import contextlib
import json
import multiprocessing
@@ -25,6 +26,7 @@ from typing import TYPE_CHECKING, Any, Literal, cast
from kubernetes import client, watch
from kubernetes.client.rest import ApiException
+from kubernetes_asyncio import client as async_client
from urllib3.exceptions import ReadTimeoutError
from airflow.providers.cncf.kubernetes.backcompat import get_logical_date_key
@@ -38,7 +40,7 @@ from
airflow.providers.cncf.kubernetes.executors.kubernetes_executor_types impor
KubernetesResults,
KubernetesWatch,
)
-from airflow.providers.cncf.kubernetes.kube_client import get_kube_client
+from airflow.providers.cncf.kubernetes.kube_client import
get_async_kube_client, get_kube_client
from airflow.providers.cncf.kubernetes.kubernetes_helper_functions import (
annotations_for_logging_task_metadata,
annotations_to_key,
@@ -51,6 +53,8 @@ from airflow.utils.log.logging_mixin import LoggingMixin
from airflow.utils.state import TaskInstanceState
if TYPE_CHECKING:
+ from collections.abc import Iterator
+
from kubernetes.client import Configuration, models as k8s
@@ -489,6 +493,12 @@ class AirflowKubernetesScheduler(LoggingMixin):
self.scheduler_job_id = scheduler_job_id
self.kube_watchers = self._make_kube_watchers()
self.team_name = team_name
+ # Async pod-creation state; populated lazily, only used when
async_pod_creation is enabled.
+ self._async_loop: asyncio.AbstractEventLoop | None = None
+ self._async_pod_client: async_client.CoreV1Api | None = None
+ self.pod_creation_max_concurrency = (
+ self.kube_config.pod_creation_max_concurrency or
self.kube_config.worker_pods_creation_batch_size
+ )
def run_pod_async(self, pod: k8s.V1Pod, **kwargs):
"""Run POD asynchronously."""
@@ -568,6 +578,18 @@ class AirflowKubernetesScheduler(LoggingMixin):
def run_next(self, next_job: KubernetesJob) -> None:
"""Receives the next job to run, builds the pod, and creates it."""
+ pod = self._build_pod_request(next_job)
+ # the watcher will monitor pods, so we do not block.
+ self.run_pod_async(pod, **self.kube_config.kube_client_request_args)
+ self.log.debug("Kubernetes Job created!")
+
+ def _build_pod_request(self, next_job: KubernetesJob) -> k8s.V1Pod:
+ """
+ Build the worker pod request object for a job.
+
+ Performs no API calls. May raise ``PodMutationHookException`` or
+ ``PodReconciliationError`` from the pod-mutation hook / reconciliation.
+ """
key = next_job.key
command = next_job.command
kube_executor_config = next_job.kube_executor_config
@@ -621,10 +643,90 @@ class AirflowKubernetesScheduler(LoggingMixin):
)
self.log.debug("Kubernetes running for command %s", command)
self.log.debug("Kubernetes launching image %s",
pod.spec.containers[0].image)
+ return pod
- # the watcher will monitor pods, so we do not block.
- self.run_pod_async(pod, **self.kube_config.kube_client_request_args)
- self.log.debug("Kubernetes Job created!")
+ def run_next_batch(self, next_jobs: list[KubernetesJob]) ->
list[tuple[KubernetesJob, Exception | None]]:
+ """
+ Build pod requests synchronously, then create them concurrently via
the async client.
+
+ Bounded by ``pod_creation_max_concurrency``. Returns one ``(job,
exception)`` per job —
+ build and create failures are returned, not raised, so the caller
handles them like the
+ sequential path.
+ """
+ built: list[tuple[KubernetesJob, k8s.V1Pod | None, Exception | None]]
= []
+ for job in next_jobs:
+ try:
+ built.append((job, self._build_pod_request(job), None))
+ except Exception as e:
+ built.append((job, None, e))
+
+ to_create: list[tuple[KubernetesJob, k8s.V1Pod]] = [
+ (job, pod) for job, pod, build_err in built if build_err is None
and pod is not None
+ ]
+ create_errors: list[Exception | None] =
self._run_pods_async(to_create) if to_create else []
+
+ # create_errors aligns 1:1 with to_create (gather preserves order);
walk it as we
+ # re-emit one (job, error) per built entry, pairing build failures
with their own error.
+ create_iter: Iterator[Exception | None] = iter(create_errors)
+ results: list[tuple[KubernetesJob, Exception | None]] = []
+ for job, _, build_err in built:
+ results.append((job, build_err if build_err is not None else
next(create_iter)))
+ return results
+
+ def _run_pods_async(self, jobs_and_pods: list[tuple[KubernetesJob,
k8s.V1Pod]]) -> list[Exception | None]:
+ """Create the given pods concurrently on a dedicated event loop; one
error (or None) per pod, in order."""
+ if self._async_loop is None:
+ self._async_loop = asyncio.new_event_loop()
+ return
self._async_loop.run_until_complete(self._create_pods_async(jobs_and_pods))
+
+ async def _create_pods_async(
+ self, jobs_and_pods: list[tuple[KubernetesJob, k8s.V1Pod]]
+ ) -> list[Exception | None]:
+ """Issue create_namespaced_pod calls concurrently, bounded by a
semaphore; one result per pod, in order."""
+ if self._async_pod_client is None:
+ self._async_pod_client = await get_async_kube_client()
+ api = self._async_pod_client
+ semaphore = asyncio.Semaphore(self.pod_creation_max_concurrency)
+ request_kwargs: dict[str, Any] =
self.kube_config.kube_client_request_args or {}
+
+ async def _create(pod: k8s.V1Pod) -> None:
+ # Sanitize with the sync client (identical to run_pod_async) to
guarantee the
+ # request body matches the sequential path exactly.
+ sanitized_pod =
self.kube_client.api_client.sanitize_for_serialization(pod)
+ async with semaphore:
+ try:
+ with Stats.timer("kubernetes_executor.pod_creation"):
+ await api.create_namespaced_pod(
+ body=sanitized_pod,
namespace=pod.metadata.namespace, **request_kwargs
+ )
+ Stats.incr("kubernetes_executor.pod_creation_status",
tags={"status": "200"})
+ except async_client.exceptions.ApiException as e:
+ Stats.incr("kubernetes_executor.pod_creation_status",
tags={"status": str(e.status)})
+ raise
+ except Exception:
+ Stats.incr("kubernetes_executor.pod_creation_status",
tags={"status": "error"})
+ raise
+
+ outcomes: list[BaseException | None] = await asyncio.gather(
+ *(_create(pod) for _, pod in jobs_and_pods), return_exceptions=True
+ )
+ return [outcome if isinstance(outcome, Exception) else None for
outcome in outcomes]
+
+ def _close_async_pod_client(self) -> None:
+ """Close the async pod client and its event loop, if they were
created."""
+ if self._async_loop is None:
+ return
+ if self._async_pod_client is not None:
+ try:
+ # CoreV1Api sets self.api_client in __init__, but
kubernetes_asyncio's
+ # swagger-codegen stubs don't expose it at the class level.
+ api_client = self._async_pod_client.api_client # type:
ignore[attr-defined]
+ self._async_loop.run_until_complete(api_client.close())
+ except Exception:
+ self.log.warning("Error while closing async pod client",
exc_info=True)
+ self._async_loop.close()
+ self._async_loop = None
+ self._async_pod_client = None
def delete_pod(self, pod_name: str, namespace: str) -> None:
"""Delete Pod from a namespace; does not raise if it does not exist."""
@@ -756,6 +858,7 @@ class AirflowKubernetesScheduler(LoggingMixin):
def terminate(self) -> None:
"""Terminates the watcher."""
+ self._close_async_pod_client()
self.log.debug("Terminating kube_watchers...")
for kube_watcher in self.kube_watchers.values():
kube_watcher.terminate()
diff --git
a/providers/cncf/kubernetes/src/airflow/providers/cncf/kubernetes/get_provider_info.py
b/providers/cncf/kubernetes/src/airflow/providers/cncf/kubernetes/get_provider_info.py
index df11e510921..8a2daec956f 100644
---
a/providers/cncf/kubernetes/src/airflow/providers/cncf/kubernetes/get_provider_info.py
+++
b/providers/cncf/kubernetes/src/airflow/providers/cncf/kubernetes/get_provider_info.py
@@ -222,6 +222,20 @@ def get_provider_info():
"example": None,
"default": "1",
},
+ "async_pod_creation": {
+ "description": "Create worker pods concurrently within
each scheduler loop instead of\nsequentially. When enabled, the
``worker_pods_creation_batch_size`` pods\ndequeued per loop are submitted to
the Kubernetes API concurrently (bounded\nby ``pod_creation_max_concurrency``)
using the asynchronous Kubernetes client.\nThis reduces the time the scheduler
loop spends blocked on pod creation when\nper-call latency is high (network
round-trip, admission webhooks). Pod\n [...]
+ "version_added": "10.20.0",
+ "type": "boolean",
+ "example": None,
+ "default": "False",
+ },
+ "pod_creation_max_concurrency": {
+ "description": "Maximum number of concurrent
pod-creation API calls when ``async_pod_creation``\nis enabled. Bounds the
burst of simultaneous requests to the Kubernetes API\nserver (and admission
webhooks) to avoid tripping API priority-and-fairness or\nrate limits. Has no
effect when ``async_pod_creation`` is False. When set to 0\nthe limit falls
back to ``worker_pods_creation_batch_size``.\n",
+ "version_added": "10.20.0",
+ "type": "integer",
+ "example": "16",
+ "default": "0",
+ },
"multi_namespace_mode": {
"description": "Allows users to launch pods in
multiple namespaces.\nWill require creating a cluster-role for the
scheduler,\nor use multi_namespace_mode_namespace_list configuration.\n",
"version_added": None,
diff --git
a/providers/cncf/kubernetes/src/airflow/providers/cncf/kubernetes/hooks/kubernetes.py
b/providers/cncf/kubernetes/src/airflow/providers/cncf/kubernetes/hooks/kubernetes.py
index 7c8140a6f07..8ebf00c69e4 100644
---
a/providers/cncf/kubernetes/src/airflow/providers/cncf/kubernetes/hooks/kubernetes.py
+++
b/providers/cncf/kubernetes/src/airflow/providers/cncf/kubernetes/hooks/kubernetes.py
@@ -35,12 +35,13 @@ from urllib3.exceptions import HTTPError
from airflow.models import Connection
from airflow.providers.cncf.kubernetes.exceptions import KubernetesApiError,
KubernetesApiPermissionError
-from airflow.providers.cncf.kubernetes.kube_client import _disable_verify_ssl,
_enable_tcp_keepalive
-from airflow.providers.cncf.kubernetes.kubernetes_helper_functions import (
- API_TIMEOUT,
- API_TIMEOUT_OFFSET_SERVER_SIDE,
- generic_api_retry,
+from airflow.providers.cncf.kubernetes.kube_client import (
+ _disable_verify_ssl,
+ _enable_tcp_keepalive,
+ _TimeoutAsyncK8sApiClient,
+ _TimeoutK8sApiClient,
)
+from airflow.providers.cncf.kubernetes.kubernetes_helper_functions import
generic_api_retry
from airflow.providers.cncf.kubernetes.utils.container import (
container_is_completed,
container_is_running,
@@ -74,55 +75,6 @@ def _load_body_to_dict(body: str) -> dict:
return body_dict
-def _get_request_timeout(timeout_seconds: int | None) -> float:
- """Get the client-side request timeout."""
- if timeout_seconds is not None and timeout_seconds > API_TIMEOUT -
API_TIMEOUT_OFFSET_SERVER_SIDE:
- return timeout_seconds + API_TIMEOUT_OFFSET_SERVER_SIDE
- return API_TIMEOUT
-
-
-class _TimeoutK8sApiClient(client.ApiClient):
- """
- Wrapper around kubernetes sync ApiClient to set default timeout.
-
- When *disable_verify_ssl* is True the TLS certificate check is turned off
- on the *client_configuration* that is passed (or on a fresh default copy)
- so that callers do not need to repeat this logic at every call-site.
- """
-
- def __init__(
- self,
- configuration: client.Configuration | None = None,
- *,
- disable_verify_ssl: bool = False,
- ) -> None:
- if disable_verify_ssl:
- if configuration is None:
- configuration = client.Configuration.get_default_copy()
- configuration.verify_ssl = False
- super().__init__(configuration=configuration)
-
- def call_api(self, *args, **kwargs):
- timeout_seconds = kwargs.get("timeout_seconds") # get server-side
timeout
- kwargs.setdefault("_request_timeout",
_get_request_timeout(timeout_seconds)) # client-side timeout
- return super().call_api(*args, **kwargs)
-
-
-class _TimeoutAsyncK8sApiClient(async_client.ApiClient):
- """Wrapper around kubernetes async ApiClient to set default timeout."""
-
- def __init__(
- self,
- configuration: async_client.Configuration | None = None,
- ) -> None:
- super().__init__(configuration=configuration)
-
- async def call_api(self, *args, **kwargs):
- timeout_seconds = kwargs.get("timeout_seconds") # server-side timeout
- kwargs.setdefault("_request_timeout",
_get_request_timeout(timeout_seconds)) # client-side timeout
- return await super().call_api(*args, **kwargs)
-
-
class PodOperatorHookProtocol(Protocol):
"""
Protocol to define methods relied upon by KubernetesPodOperator.
diff --git
a/providers/cncf/kubernetes/src/airflow/providers/cncf/kubernetes/kube_client.py
b/providers/cncf/kubernetes/src/airflow/providers/cncf/kubernetes/kube_client.py
index a4c5f405577..79e2b802e01 100644
---
a/providers/cncf/kubernetes/src/airflow/providers/cncf/kubernetes/kube_client.py
+++
b/providers/cncf/kubernetes/src/airflow/providers/cncf/kubernetes/kube_client.py
@@ -19,6 +19,7 @@
from __future__ import annotations
import logging
+from typing import Any
import urllib3.util
@@ -30,6 +31,12 @@ try:
from kubernetes import client, config
from kubernetes.client import Configuration
from kubernetes.client.rest import ApiException
+ from kubernetes_asyncio import client as async_client, config as
async_config
+
+ from airflow.providers.cncf.kubernetes.kubernetes_helper_functions import (
+ API_TIMEOUT,
+ API_TIMEOUT_OFFSET_SERVER_SIDE,
+ )
has_kubernetes = True
@@ -43,6 +50,56 @@ try:
configuration.verify_ssl = False
Configuration.set_default(configuration)
+ def _get_request_timeout(timeout_seconds: int | None) -> float:
+ """Get the client-side request timeout."""
+ if timeout_seconds is not None and timeout_seconds > API_TIMEOUT -
API_TIMEOUT_OFFSET_SERVER_SIDE:
+ return timeout_seconds + API_TIMEOUT_OFFSET_SERVER_SIDE
+ return API_TIMEOUT
+
+ class _TimeoutK8sApiClient(client.ApiClient):
+ """
+ Wrapper around kubernetes sync ApiClient to set default timeout.
+
+ When *disable_verify_ssl* is True the TLS certificate check is turned
off
+ on the *client_configuration* that is passed (or on a fresh default
copy)
+ so that callers do not need to repeat this logic at every call-site.
+ """
+
+ def __init__(
+ self,
+ configuration: client.Configuration | None = None,
+ *,
+ disable_verify_ssl: bool = False,
+ ) -> None:
+ if disable_verify_ssl:
+ if configuration is None:
+ configuration = client.Configuration.get_default_copy()
+ configuration.verify_ssl = False
+ super().__init__(configuration=configuration)
+
+ def call_api(self, *args: Any, **kwargs: Any) -> Any:
+ timeout_seconds = kwargs.get("timeout_seconds") # get server-side
timeout
+ kwargs.setdefault(
+ "_request_timeout", _get_request_timeout(timeout_seconds)
+ ) # client-side timeout
+ return super().call_api(*args, **kwargs)
+
+ class _TimeoutAsyncK8sApiClient(async_client.ApiClient):
+ """Wrapper around kubernetes async ApiClient to set default timeout."""
+
+ def __init__(
+ self,
+ configuration: async_client.Configuration | None = None,
+ ) -> None:
+ super().__init__(configuration=configuration)
+
+ async def call_api(self, *args: Any, **kwargs: Any) -> Any:
+ timeout_seconds = kwargs.get("timeout_seconds") # server-side
timeout
+ kwargs.setdefault(
+ "_request_timeout", _get_request_timeout(timeout_seconds)
+ ) # client-side timeout
+ return await super().call_api(*args, **kwargs)
+
except ImportError as e:
# We need an exception class to be able to use it in ``except`` elsewhere
# in the code base
@@ -154,3 +211,48 @@ def get_kube_client(
api_client = client.ApiClient(configuration=configuration)
return client.CoreV1Api(api_client)
+
+
+async def get_async_kube_client(
+ in_cluster: bool | None = None,
+ cluster_context: str | None = None,
+ config_file: str | None = None,
+) -> async_client.CoreV1Api:
+ """
+ Retrieve an asynchronous Kubernetes client.
+
+ Mirrors :func:`get_kube_client` but builds a ``kubernetes_asyncio`` client
so that
+ pod-creation API calls can be issued concurrently by the
KubernetesExecutor. Reads the
+ same configuration keys (``in_cluster``, ``cluster_context``,
``config_file``,
+ ``verify_ssl``, ``ssl_ca_cert``).
+
+ :param in_cluster: whether we are in cluster
+ :param cluster_context: context of the cluster
+ :param config_file: configuration file
+ :return: asynchronous kubernetes client
+ """
+ if not has_kubernetes:
+ raise _import_err
+ if in_cluster is None:
+ in_cluster = conf.getboolean("kubernetes_executor", "in_cluster")
+
+ configuration = async_client.Configuration()
+ if not conf.getboolean("kubernetes_executor", "verify_ssl"):
+ configuration.verify_ssl = False
+
+ if in_cluster:
+ async_config.load_incluster_config(client_configuration=configuration)
+ else:
+ if cluster_context is None:
+ cluster_context = conf.get("kubernetes_executor",
"cluster_context", fallback=None)
+ if config_file is None:
+ config_file = conf.get("kubernetes_executor", "config_file",
fallback=None)
+ await async_config.load_kube_config(
+ config_file=config_file, context=cluster_context,
client_configuration=configuration
+ )
+
+ ssl_ca_cert = conf.get("kubernetes_executor", "ssl_ca_cert")
+ if ssl_ca_cert:
+ configuration.ssl_ca_cert = ssl_ca_cert
+
+ return async_client.CoreV1Api(_TimeoutAsyncK8sApiClient(configuration))
diff --git
a/providers/cncf/kubernetes/src/airflow/providers/cncf/kubernetes/kube_config.py
b/providers/cncf/kubernetes/src/airflow/providers/cncf/kubernetes/kube_config.py
index cc9d7fc08fe..f9462bdaa99 100644
---
a/providers/cncf/kubernetes/src/airflow/providers/cncf/kubernetes/kube_config.py
+++
b/providers/cncf/kubernetes/src/airflow/providers/cncf/kubernetes/kube_config.py
@@ -67,6 +67,13 @@ class KubeConfig:
self.worker_pods_creation_batch_size = self._conf.getint(
self.kubernetes_section, "worker_pods_creation_batch_size"
)
+ self.async_pod_creation = self._conf.getboolean(
+ self.kubernetes_section, "async_pod_creation", fallback=False
+ )
+ # 0 means "fall back to worker_pods_creation_batch_size" (resolved in
the scheduler).
+ self.pod_creation_max_concurrency = self._conf.getint(
+ self.kubernetes_section, "pod_creation_max_concurrency", fallback=0
+ )
self.worker_container_repository = self._conf.get(
self.kubernetes_section, "worker_container_repository"
)
diff --git
a/providers/cncf/kubernetes/src/airflow/providers/cncf/kubernetes/kubernetes_helper_functions.py
b/providers/cncf/kubernetes/src/airflow/providers/cncf/kubernetes/kubernetes_helper_functions.py
index 5a41da5478f..9b32fb5967f 100644
---
a/providers/cncf/kubernetes/src/airflow/providers/cncf/kubernetes/kubernetes_helper_functions.py
+++
b/providers/cncf/kubernetes/src/airflow/providers/cncf/kubernetes/kubernetes_helper_functions.py
@@ -24,6 +24,7 @@ from typing import TYPE_CHECKING
import pendulum
import tenacity
+from aiohttp import ClientConnectionError
from kubernetes.client.rest import ApiException as SyncApiException
from kubernetes_asyncio.client.exceptions import ApiException as
AsyncApiException
from slugify import slugify
@@ -60,13 +61,21 @@ API_RETRY_WAIT_MAX = conf.getfloat("workers",
"api_retry_wait_max", fallback=15)
_default_wait = tenacity.wait_exponential(min=API_RETRY_WAIT_MIN,
max=API_RETRY_WAIT_MAX)
TRANSIENT_STATUS_CODES = {409, 429, 500, 502, 503, 504}
+# Connection-level failures (socket reset, DNS blip, read timeout) worth
retrying — the api server
+# never saw the request, or its reply was lost. Covers both kube clients:
urllib3 (sync) and aiohttp
+# (async). Shared so this decorator and the KubernetesExecutor agree on what
counts as transient.
+TRANSIENT_CONNECTION_ERRORS: tuple[type[BaseException], ...] = (
+ HTTPError,
+ ClientConnectionError,
+ KubernetesApiException,
+)
def _should_retry_api(exc: BaseException) -> bool:
"""Retry on selected ApiException status codes, plus plain HTTP/timeout
errors."""
if isinstance(exc, (SyncApiException, AsyncApiException)):
return exc.status in TRANSIENT_STATUS_CODES
- return isinstance(exc, (HTTPError, KubernetesApiException))
+ return isinstance(exc, TRANSIENT_CONNECTION_ERRORS)
class WaitRetryAfterOrExponential(tenacity.wait.wait_base):
diff --git
a/providers/cncf/kubernetes/tests/unit/cncf/kubernetes/executors/test_kubernetes_executor.py
b/providers/cncf/kubernetes/tests/unit/cncf/kubernetes/executors/test_kubernetes_executor.py
index ee541bd83fd..5f1c03e7d45 100644
---
a/providers/cncf/kubernetes/tests/unit/cncf/kubernetes/executors/test_kubernetes_executor.py
+++
b/providers/cncf/kubernetes/tests/unit/cncf/kubernetes/executors/test_kubernetes_executor.py
@@ -21,14 +21,17 @@ import re
import string
import time
from datetime import datetime, timedelta
+from queue import Queue
from unittest import mock
import pytest
import yaml
+from aiohttp import ClientConnectionError
from kubernetes.client import models as k8s
from kubernetes.client.rest import ApiException
from sqlalchemy import inspect
-from urllib3 import HTTPResponse
+from urllib3 import HTTPConnectionPool, HTTPResponse
+from urllib3.exceptions import MaxRetryError, ProtocolError
from airflow.jobs.job import Job
from airflow.models.taskinstancekey import TaskInstanceKey
@@ -877,6 +880,46 @@ class TestKubernetesExecutor:
None,
id="500 Internal Server Error (webhook failure) (retry
failed)",
),
+ pytest.param(
+ HTTPResponse(body='{"message": "Bad Gateway"}', status=502),
+ 1,
+ True,
+ State.SUCCESS,
+ None,
+ id="502 Bad Gateway (transient, requeued, retry next loop)",
+ ),
+ pytest.param(
+ HTTPResponse(
+ body='{"message": "apiserver is shutting down"}',
+ status=503,
+ headers={"Retry-After": "1"},
+ ),
+ 1,
+ True,
+ State.SUCCESS,
+ 1,
+ id="503 Service Unavailable (Retry-After honored, requeued
after retry delay)",
+ ),
+ pytest.param(
+ HTTPResponse(body='{"message": "Gateway Timeout"}',
status=504),
+ 1,
+ True,
+ State.SUCCESS,
+ None,
+ id="504 Gateway Timeout (transient, requeued, retry next
loop)",
+ ),
+ pytest.param(
+ HTTPResponse(
+ body='{"message": "apiserver is shutting down"}',
+ status=503,
+ headers={"Retry-After": "1"},
+ ),
+ 1,
+ True,
+ State.FAILED,
+ 1,
+ id="503 Service Unavailable (retries exhausted, failed)",
+ ),
],
)
@mock.patch("airflow.providers.cncf.kubernetes.executors.kubernetes_executor_utils.KubernetesJobWatcher")
@@ -1126,6 +1169,474 @@ class TestKubernetesExecutor:
finally:
kubernetes_executor.end()
+ @pytest.mark.skipif(
+ AirflowKubernetesScheduler is None, reason="kubernetes python package
is not installed"
+ )
+ @pytest.mark.parametrize(
+ ("exc", "task_publish_max_retries", "should_requeue"),
+ [
+ pytest.param(ProtocolError("Connection aborted."), 1, True,
id="connection reset (requeued)"),
+ pytest.param(
+ MaxRetryError(
+ HTTPConnectionPool("localhost"),
"/api/v1/namespaces/default/pods", Exception("refused")
+ ),
+ 1,
+ True,
+ id="client connect retries exhausted (requeued)",
+ ),
+ pytest.param(
+ ProtocolError("Connection aborted."), 0, False, id="connection
reset, retries off (failed)"
+ ),
+ ],
+ )
+
@mock.patch("airflow.providers.cncf.kubernetes.executors.kubernetes_executor_utils.KubernetesJobWatcher")
+
@mock.patch("airflow.providers.cncf.kubernetes.kube_client.get_kube_client")
+ def test_run_next_connection_error_requeue(
+ self,
+ mock_get_kube_client,
+ mock_kubernetes_job_watcher,
+ exc,
+ task_publish_max_retries,
+ should_requeue,
+ data_file,
+ ):
+ """A transient connection error on the sync client re-queues the task
instead of failing it."""
+ template_file =
data_file("pods/generator_base_with_secrets.yaml").as_posix()
+
+ mock_kube_client = mock.patch("kubernetes.client.CoreV1Api",
autospec=True)
+ mock_kube_client.create_namespaced_pod =
mock.MagicMock(side_effect=exc)
+ mock_get_kube_client.return_value = mock_kube_client
+ mock_api_client = mock.MagicMock()
+ mock_api_client.sanitize_for_serialization.return_value = {}
+ mock_kube_client.api_client = mock_api_client
+
+ config = {("kubernetes_executor", "pod_template_file"): template_file}
+ with conf_vars(config):
+ kubernetes_executor = self.kubernetes_executor
+ kubernetes_executor.task_publish_max_retries =
task_publish_max_retries
+ kubernetes_executor.start()
+ try:
+ task_instance_key = TaskInstanceKey("dag", "task", "run_id", 1)
+ kubernetes_executor.execute_async(
+ key=task_instance_key,
+ queue=None,
+ command=["airflow", "tasks", "run", "true",
"some_parameter"],
+ )
+ kubernetes_executor.sync()
+
+ assert mock_kube_client.create_namespaced_pod.call_count == 1
+ if should_requeue:
+ assert not kubernetes_executor.task_queue.empty()
+ else:
+ assert kubernetes_executor.task_queue.empty()
+ assert
kubernetes_executor.event_buffer[task_instance_key][0] == State.FAILED
+ finally:
+ kubernetes_executor.end()
+
+ @pytest.mark.skipif(
+ AirflowKubernetesScheduler is None, reason="kubernetes python package
is not installed"
+ )
+ @mock.patch(
+
"airflow.providers.cncf.kubernetes.executors.kubernetes_executor_utils.get_async_kube_client",
+ new_callable=mock.AsyncMock,
+ )
+
@mock.patch("airflow.providers.cncf.kubernetes.executors.kubernetes_executor_utils.KubernetesJobWatcher")
+
@mock.patch("airflow.providers.cncf.kubernetes.kube_client.get_kube_client")
+ def test_async_pod_creation_creates_all_pods(
+ self, mock_get_kube_client, mock_kubernetes_job_watcher,
mock_get_async_client, data_file
+ ):
+ """With async_pod_creation enabled, every dequeued pod is created via
the async client."""
+ template_file =
data_file("pods/generator_base_with_secrets.yaml").as_posix()
+ mock_kube_client = mock.MagicMock()
+ mock_kube_client.api_client.sanitize_for_serialization.return_value =
{}
+ mock_get_kube_client.return_value = mock_kube_client
+
+ mock_async_api = mock.MagicMock()
+ mock_async_api.create_namespaced_pod = mock.AsyncMock()
+ mock_async_api.api_client.close = mock.AsyncMock()
+ mock_get_async_client.return_value = mock_async_api
+
+ config = {
+ ("kubernetes_executor", "pod_template_file"): template_file,
+ ("kubernetes_executor", "async_pod_creation"): "True",
+ ("kubernetes_executor", "pod_creation_max_concurrency"): "0",
+ ("kubernetes_executor", "worker_pods_creation_batch_size"): "16",
+ }
+ with conf_vars(config):
+ executor = KubernetesExecutor()
+ executor.job_id = 5
+ executor._last_completed_pod_adoption = time.monotonic()
+ executor.start()
+ try:
+ for i in range(5):
+ executor.execute_async(
+ key=TaskInstanceKey("dag", f"task{i}", "run_id", 1),
+ queue=None,
+ command=["airflow", "tasks", "run", "true", "x"],
+ )
+ executor.sync()
+ assert mock_async_api.create_namespaced_pod.await_count == 5
+ assert executor.task_queue.empty()
+ # max_concurrency=0 falls back to
worker_pods_creation_batch_size
+ assert executor.kube_scheduler.pod_creation_max_concurrency ==
16
+ finally:
+ executor.end()
+
+ @pytest.mark.skipif(
+ AirflowKubernetesScheduler is None, reason="kubernetes python package
is not installed"
+ )
+ @mock.patch(
+
"airflow.providers.cncf.kubernetes.executors.kubernetes_executor_utils.get_async_kube_client",
+ new_callable=mock.AsyncMock,
+ )
+
@mock.patch("airflow.providers.cncf.kubernetes.executors.kubernetes_executor_utils.KubernetesJobWatcher")
+
@mock.patch("airflow.providers.cncf.kubernetes.kube_client.get_kube_client")
+ def test_async_pod_creation_respects_concurrency_limit(
+ self, mock_get_kube_client, mock_kubernetes_job_watcher,
mock_get_async_client, data_file
+ ):
+ """The number of in-flight create calls never exceeds
pod_creation_max_concurrency."""
+ import asyncio as _asyncio
+
+ template_file =
data_file("pods/generator_base_with_secrets.yaml").as_posix()
+ mock_kube_client = mock.MagicMock()
+ mock_kube_client.api_client.sanitize_for_serialization.return_value =
{}
+ mock_get_kube_client.return_value = mock_kube_client
+
+ inflight = {"current": 0, "peak": 0}
+
+ async def _tracked_create(*args, **kwargs):
+ inflight["current"] += 1
+ inflight["peak"] = max(inflight["peak"], inflight["current"])
+ await _asyncio.sleep(0.02)
+ inflight["current"] -= 1
+
+ mock_async_api = mock.MagicMock()
+ mock_async_api.create_namespaced_pod =
mock.AsyncMock(side_effect=_tracked_create)
+ mock_async_api.api_client.close = mock.AsyncMock()
+ mock_get_async_client.return_value = mock_async_api
+
+ config = {
+ ("kubernetes_executor", "pod_template_file"): template_file,
+ ("kubernetes_executor", "async_pod_creation"): "True",
+ ("kubernetes_executor", "pod_creation_max_concurrency"): "3",
+ ("kubernetes_executor", "worker_pods_creation_batch_size"): "16",
+ }
+ with conf_vars(config):
+ executor = KubernetesExecutor()
+ executor.job_id = 5
+ executor._last_completed_pod_adoption = time.monotonic()
+ executor.start()
+ try:
+ for i in range(9):
+ executor.execute_async(
+ key=TaskInstanceKey("dag", f"task{i}", "run_id", 1),
+ queue=None,
+ command=["airflow", "tasks", "run", "true", "x"],
+ )
+ executor.sync()
+ assert mock_async_api.create_namespaced_pod.await_count == 9
+ assert inflight["peak"] <= 3
+ finally:
+ executor.end()
+
+ @pytest.mark.skipif(
+ AirflowKubernetesScheduler is None, reason="kubernetes python package
is not installed"
+ )
+ @mock.patch(
+
"airflow.providers.cncf.kubernetes.executors.kubernetes_executor_utils.get_async_kube_client",
+ new_callable=mock.AsyncMock,
+ )
+
@mock.patch("airflow.providers.cncf.kubernetes.executors.kubernetes_executor_utils.KubernetesJobWatcher")
+
@mock.patch("airflow.providers.cncf.kubernetes.kube_client.get_kube_client")
+ def test_async_pod_creation_requeues_on_exceeded_quota(
+ self, mock_get_kube_client, mock_kubernetes_job_watcher,
mock_get_async_client, data_file
+ ):
+ """An async-client quota error requeues the task via the shared
pod-publish error handler."""
+ from kubernetes_asyncio.client.exceptions import ApiException as
AsyncApiException
+
+ template_file =
data_file("pods/generator_base_with_secrets.yaml").as_posix()
+ mock_kube_client = mock.MagicMock()
+ mock_kube_client.api_client.sanitize_for_serialization.return_value =
{}
+ mock_get_kube_client.return_value = mock_kube_client
+
+ quota_exc = AsyncApiException(status=403, reason="Forbidden")
+ quota_exc.body = '{"message": "pods \\"x\\" is forbidden: exceeded
quota: my-quota"}'
+ quota_exc.headers = {}
+
+ mock_async_api = mock.MagicMock()
+ mock_async_api.create_namespaced_pod =
mock.AsyncMock(side_effect=quota_exc)
+ mock_async_api.api_client.close = mock.AsyncMock()
+ mock_get_async_client.return_value = mock_async_api
+
+ config = {
+ ("kubernetes_executor", "pod_template_file"): template_file,
+ ("kubernetes_executor", "async_pod_creation"): "True",
+ }
+ with conf_vars(config):
+ executor = KubernetesExecutor()
+ executor.job_id = 5
+ executor._last_completed_pod_adoption = time.monotonic()
+ executor.task_publish_max_retries = 1
+ executor.start()
+ try:
+ executor.execute_async(
+ key=TaskInstanceKey("dag", "task", "run_id", 1),
+ queue=None,
+ command=["airflow", "tasks", "run", "true", "x"],
+ )
+ executor.sync()
+ assert mock_async_api.create_namespaced_pod.await_count == 1
+ # Quota error is retryable -> task is requeued rather than
failed.
+ assert not executor.task_queue.empty()
+ finally:
+ executor.end()
+
+ @pytest.mark.skipif(
+ AirflowKubernetesScheduler is None, reason="kubernetes python package
is not installed"
+ )
+ @mock.patch(
+
"airflow.providers.cncf.kubernetes.executors.kubernetes_executor_utils.get_async_kube_client",
+ new_callable=mock.AsyncMock,
+ )
+
@mock.patch("airflow.providers.cncf.kubernetes.executors.kubernetes_executor_utils.KubernetesJobWatcher")
+
@mock.patch("airflow.providers.cncf.kubernetes.kube_client.get_kube_client")
+ def test_async_pod_creation_rate_limit_sets_create_pods_after(
+ self, mock_get_kube_client, mock_kubernetes_job_watcher,
mock_get_async_client, data_file
+ ):
+ """A 429 from the async client sets create_pods_after so the next loop
backs off."""
+ from kubernetes_asyncio.client.exceptions import ApiException as
AsyncApiException
+
+ template_file =
data_file("pods/generator_base_with_secrets.yaml").as_posix()
+ mock_kube_client = mock.MagicMock()
+ mock_kube_client.api_client.sanitize_for_serialization.return_value =
{}
+ mock_get_kube_client.return_value = mock_kube_client
+
+ rate_exc = AsyncApiException(status=429, reason="Too Many Requests")
+ rate_exc.body = '{"message": "slow down"}'
+ rate_exc.headers = {"Retry-After": "1"}
+
+ mock_async_api = mock.MagicMock()
+ mock_async_api.create_namespaced_pod =
mock.AsyncMock(side_effect=rate_exc)
+ mock_async_api.api_client.close = mock.AsyncMock()
+ mock_get_async_client.return_value = mock_async_api
+
+ config = {
+ ("kubernetes_executor", "pod_template_file"): template_file,
+ ("kubernetes_executor", "async_pod_creation"): "True",
+ }
+ with conf_vars(config):
+ executor = KubernetesExecutor()
+ executor.job_id = 5
+ executor._last_completed_pod_adoption = time.monotonic()
+ executor.task_publish_max_retries = 1
+ executor.start()
+ try:
+ executor.execute_async(
+ key=TaskInstanceKey("dag", "task", "run_id", 1),
+ queue=None,
+ command=["airflow", "tasks", "run", "true", "x"],
+ )
+ executor.sync()
+ assert executor.create_pods_after is not None
+ assert not executor.task_queue.empty()
+ finally:
+ executor.end()
+
+ @pytest.mark.skipif(
+ AirflowKubernetesScheduler is None, reason="kubernetes python package
is not installed"
+ )
+ @mock.patch(
+
"airflow.providers.cncf.kubernetes.executors.kubernetes_executor_utils.get_async_kube_client",
+ new_callable=mock.AsyncMock,
+ )
+
@mock.patch("airflow.providers.cncf.kubernetes.executors.kubernetes_executor_utils.KubernetesJobWatcher")
+
@mock.patch("airflow.providers.cncf.kubernetes.kube_client.get_kube_client")
+ def test_async_pod_creation_requeues_and_backs_off_on_503(
+ self, mock_get_kube_client, mock_kubernetes_job_watcher,
mock_get_async_client, data_file
+ ):
+ """A 503 + Retry-After from the async client (apiserver shutting down)
requeues and backs off."""
+ from kubernetes_asyncio.client.exceptions import ApiException as
AsyncApiException
+
+ template_file =
data_file("pods/generator_base_with_secrets.yaml").as_posix()
+ mock_kube_client = mock.MagicMock()
+ mock_kube_client.api_client.sanitize_for_serialization.return_value =
{}
+ mock_get_kube_client.return_value = mock_kube_client
+
+ unavailable_exc = AsyncApiException(status=503, reason="Service
Unavailable")
+ unavailable_exc.body = '{"message": "apiserver is shutting down"}'
+ unavailable_exc.headers = {"Retry-After": "1"}
+
+ mock_async_api = mock.MagicMock()
+ mock_async_api.create_namespaced_pod =
mock.AsyncMock(side_effect=unavailable_exc)
+ mock_async_api.api_client.close = mock.AsyncMock()
+ mock_get_async_client.return_value = mock_async_api
+
+ config = {
+ ("kubernetes_executor", "pod_template_file"): template_file,
+ ("kubernetes_executor", "async_pod_creation"): "True",
+ }
+ with conf_vars(config):
+ executor = KubernetesExecutor()
+ executor.job_id = 5
+ executor._last_completed_pod_adoption = time.monotonic()
+ executor.task_publish_max_retries = 1
+ executor.start()
+ try:
+ executor.execute_async(
+ key=TaskInstanceKey("dag", "task", "run_id", 1),
+ queue=None,
+ command=["airflow", "tasks", "run", "true", "x"],
+ )
+ executor.sync()
+ assert mock_async_api.create_namespaced_pod.await_count == 1
+ assert executor.create_pods_after is not None
+ assert not executor.task_queue.empty()
+ finally:
+ executor.end()
+
+ @pytest.mark.skipif(
+ AirflowKubernetesScheduler is None, reason="kubernetes python package
is not installed"
+ )
+ @mock.patch(
+
"airflow.providers.cncf.kubernetes.executors.kubernetes_executor_utils.get_async_kube_client",
+ new_callable=mock.AsyncMock,
+ )
+
@mock.patch("airflow.providers.cncf.kubernetes.executors.kubernetes_executor_utils.KubernetesJobWatcher")
+
@mock.patch("airflow.providers.cncf.kubernetes.kube_client.get_kube_client")
+ def test_async_pod_creation_requeues_on_connection_error(
+ self, mock_get_kube_client, mock_kubernetes_job_watcher,
mock_get_async_client, data_file
+ ):
+ """A connection error from the async (aiohttp) client requeues the
task via the shared handler."""
+ template_file =
data_file("pods/generator_base_with_secrets.yaml").as_posix()
+ mock_kube_client = mock.MagicMock()
+ mock_kube_client.api_client.sanitize_for_serialization.return_value =
{}
+ mock_get_kube_client.return_value = mock_kube_client
+
+ mock_async_api = mock.MagicMock()
+ mock_async_api.create_namespaced_pod = mock.AsyncMock(
+ side_effect=ClientConnectionError("Cannot connect to host")
+ )
+ mock_async_api.api_client.close = mock.AsyncMock()
+ mock_get_async_client.return_value = mock_async_api
+
+ config = {
+ ("kubernetes_executor", "pod_template_file"): template_file,
+ ("kubernetes_executor", "async_pod_creation"): "True",
+ }
+ with conf_vars(config):
+ executor = KubernetesExecutor()
+ executor.job_id = 5
+ executor._last_completed_pod_adoption = time.monotonic()
+ executor.task_publish_max_retries = 1
+ executor.start()
+ try:
+ executor.execute_async(
+ key=TaskInstanceKey("dag", "task", "run_id", 1),
+ queue=None,
+ command=["airflow", "tasks", "run", "true", "x"],
+ )
+ executor.sync()
+ assert mock_async_api.create_namespaced_pod.await_count == 1
+ # Connection error is transient -> requeued (not failed).
+ assert not executor.task_queue.empty()
+ finally:
+ executor.end()
+
+ @pytest.mark.skipif(
+ AirflowKubernetesScheduler is None, reason="kubernetes python package
is not installed"
+ )
+
@mock.patch("airflow.providers.cncf.kubernetes.executors.kubernetes_executor_utils.KubernetesJobWatcher")
+
@mock.patch("airflow.providers.cncf.kubernetes.kube_client.get_kube_client")
+ def test_concurrent_path_fails_errored_and_completes_successful(
+ self, mock_get_kube_client, mock_kubernetes_job_watcher
+ ):
+ """A build/create failure for one job in a batch must not affect the
others (isolation)."""
+ from
airflow.providers.cncf.kubernetes.executors.kubernetes_executor_types import
KubernetesJob
+
+ executor = self.kubernetes_executor
+ executor.kube_config.worker_pods_creation_batch_size = 16
+ executor.kube_scheduler = mock.MagicMock()
+ # start() is not called here, so the lazily-created task_queue (None
until start) is
+ # supplied directly — this unit-tests _create_pods_concurrently's
error isolation without
+ # spinning up the executor's Manager process.
+ executor.task_queue = Queue()
+ ok_key = TaskInstanceKey("dag", "ok", "run_id", 1)
+ bad_key = TaskInstanceKey("dag", "bad", "run_id", 1)
+ ok_job = KubernetesJob(ok_key, ["airflow", "tasks", "run"], {}, None)
+ bad_job = KubernetesJob(bad_key, ["airflow", "tasks", "run"], {}, None)
+ executor.task_queue.put(ok_job)
+ executor.task_queue.put(bad_job)
+ executor.kube_scheduler.run_next_batch.return_value = [
+ (ok_job, None),
+ (bad_job, PodReconciliationError("boom")),
+ ]
+
+ executor._create_pods_concurrently()
+
+ executor.kube_scheduler.run_next_batch.assert_called_once()
+ assert executor.task_queue.empty()
+ assert executor.event_buffer[bad_key][0] == State.FAILED
+ assert ok_key not in executor.event_buffer
+
+ @pytest.mark.db_test
+ @pytest.mark.skipif(not AIRFLOW_V_3_0_PLUS, reason="ExecuteTask workloads
require Airflow 3.0+")
+ @pytest.mark.skipif(
+ AirflowKubernetesScheduler is None, reason="kubernetes python package
is not installed"
+ )
+ @mock.patch(
+
"airflow.providers.cncf.kubernetes.executors.kubernetes_executor_utils.get_async_kube_client",
+ new_callable=mock.AsyncMock,
+ )
+
@mock.patch("airflow.providers.cncf.kubernetes.executors.kubernetes_executor_utils.KubernetesJobWatcher")
+
@mock.patch("airflow.providers.cncf.kubernetes.kube_client.get_kube_client")
+ def test_async_pod_creation_with_executetask_workload(
+ self,
+ mock_get_kube_client,
+ mock_kubernetes_job_watcher,
+ mock_get_async_client,
+ create_task_instance,
+ data_file,
+ ):
+ """The concurrent path builds and creates a pod from an AF3
ExecuteTask workload.
+
+ Real AF3 schedulers feed the executor ExecuteTask workloads (command
== [workload]),
+ not the legacy ["airflow", "tasks", "run", ...] list. This exercises
that branch of
+ the pod build through the concurrent creation path.
+ """
+ from airflow.executors import workloads
+ from
airflow.providers.cncf.kubernetes.executors.kubernetes_executor_types import
KubernetesJob
+
+ template_file =
data_file("pods/generator_base_with_secrets.yaml").as_posix()
+ mock_kube_client = mock.MagicMock()
+ mock_kube_client.api_client.sanitize_for_serialization.return_value =
{}
+ mock_get_kube_client.return_value = mock_kube_client
+
+ mock_async_api = mock.MagicMock()
+ mock_async_api.create_namespaced_pod = mock.AsyncMock()
+ mock_async_api.api_client.close = mock.AsyncMock()
+ mock_get_async_client.return_value = mock_async_api
+
+ ti = create_task_instance(dag_id="wl_dag", task_id="wl_task",
run_id="wl_run")
+ workload = workloads.ExecuteTask.make(ti)
+
+ config = {
+ ("kubernetes_executor", "pod_template_file"): template_file,
+ ("kubernetes_executor", "async_pod_creation"): "True",
+ }
+ with conf_vars(config):
+ executor = KubernetesExecutor()
+ executor.job_id = 5
+ executor._last_completed_pod_adoption = time.monotonic()
+ executor.start()
+ try:
+ job = KubernetesJob(ti.key, [workload], None, template_file)
+ results = executor.kube_scheduler.run_next_batch([job])
+ assert len(results) == 1
+ assert results[0][1] is None # workload built + pod created,
no error
+ mock_async_api.create_namespaced_pod.assert_awaited_once()
+ finally:
+ executor.end()
+
@mock.patch("airflow.providers.cncf.kubernetes.executors.kubernetes_executor.KubeConfig")
@mock.patch("airflow.providers.cncf.kubernetes.executors.kubernetes_executor.KubernetesExecutor.sync")
@mock.patch("airflow.executors.base_executor.BaseExecutor.trigger_tasks")
diff --git
a/providers/cncf/kubernetes/tests/unit/cncf/kubernetes/test_kube_client.py
b/providers/cncf/kubernetes/tests/unit/cncf/kubernetes/test_kube_client.py
new file mode 100644
index 00000000000..53363d49d29
--- /dev/null
+++ b/providers/cncf/kubernetes/tests/unit/cncf/kubernetes/test_kube_client.py
@@ -0,0 +1,39 @@
+# 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.
+from __future__ import annotations
+
+from unittest import mock
+
+import pytest
+
+from airflow.providers.cncf.kubernetes.kube_client import
_TimeoutAsyncK8sApiClient, get_async_kube_client
+
+from tests_common.test_utils.config import conf_vars
+
+
+class TestGetAsyncKubeClient:
+ @pytest.mark.asyncio
+ @mock.patch("kubernetes_asyncio.config.load_incluster_config")
+ async def test_wraps_client_with_request_timeout(self,
mock_load_incluster):
+ """The async client carries the shared client-side request-timeout
wrapper."""
+ with conf_vars(
+ {("kubernetes_executor", "verify_ssl"): "True",
("kubernetes_executor", "ssl_ca_cert"): ""}
+ ):
+ api = await get_async_kube_client(in_cluster=True)
+
+ assert isinstance(api.api_client, _TimeoutAsyncK8sApiClient)
+ mock_load_incluster.assert_called_once()
diff --git
a/shared/observability/src/airflow_shared/observability/metrics/metrics_template.yaml
b/shared/observability/src/airflow_shared/observability/metrics/metrics_template.yaml
index a561db2d7ae..4a641851991 100644
---
a/shared/observability/src/airflow_shared/observability/metrics/metrics_template.yaml
+++
b/shared/observability/src/airflow_shared/observability/metrics/metrics_template.yaml
@@ -740,6 +740,19 @@ metrics:
legacy_name: "-"
name_variables: []
+ - name: "kubernetes_executor.pod_creation_batch_duration"
+ description: "Milliseconds taken to create one batch of worker pods in a
Kubernetes Executor scheduler
+ loop, covering both the sequential and the concurrent
(``async_pod_creation``) creation paths."
+ type: "timer"
+ legacy_name: "-"
+ name_variables: []
+
+ - name: "kubernetes_executor.pod_creation_batch_size"
+ description: "Number of worker pods created in one Kubernetes Executor
scheduler loop."
+ type: "gauge"
+ legacy_name: "-"
+ name_variables: []
+
- name: "kubernetes_executor.pod_deletion"
description: "Milliseconds taken for a Kubernetes delete_namespaced_pod
call from the Kubernetes Executor"
type: "timer"