This is an automated email from the ASF dual-hosted git repository.
jason810496 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 512a0445875 Decide pod_template and image based on Coordinator for
lang-SDK tasks on KubernetesExecutor (#68713)
512a0445875 is described below
commit 512a0445875c950ff50f9fbd890751da20e92a09
Author: Jason(Zhe-You) Liu <[email protected]>
AuthorDate: Mon Jun 29 20:10:02 2026 +0900
Decide pod_template and image based on Coordinator for lang-SDK tasks on
KubernetesExecutor (#68713)
---
.../kubernetes/executors/kubernetes_executor.py | 82 ++++++++-
.../executors/kubernetes_executor_types.py | 1 +
.../executors/kubernetes_executor_utils.py | 3 +-
.../executors/test_kubernetes_executor.py | 198 ++++++++++++++++++++-
4 files changed, 278 insertions(+), 6 deletions(-)
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 4ddb189390b..154024add26 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
@@ -198,6 +198,60 @@ class KubernetesExecutor(BaseExecutor):
scheduler_job_id=self.scheduler_job_id,
)
+ def _coordinator_extra(self, queue: str | None) -> dict[str, Any] | None:
+ """
+ Return the ``extra`` mapping a coordinator declares for *queue*, if
any.
+
+ Read from the coordinator's declarative ``[sdk]`` config without
importing
+ or instantiating the coordinator. The coordinator manager only exists
on
+ Airflow 3.3+; on older Task SDKs the import fails and we fall back to
no
+ extra. A malformed ``[sdk] coordinators`` / ``queue_to_coordinator``
config
+ must not crash the scheduler on this first lookup either, so an invalid
+ config also falls back to no extra. The exception types are imported
from
+ ``airflow.sdk`` so they match whatever Task SDK actually raised them.
+ """
+ if not queue:
+ return None
+ try:
+ from airflow.sdk.exceptions import AirflowConfigException
+ from airflow.sdk.execution_time.coordinator import
get_coordinator_manager
+ except ImportError:
+ return None
+ try:
+ return get_coordinator_manager().extra_for_queue(queue)
+ except (AirflowConfigException, ValueError):
+ self.log.warning(
+ "Ignoring coordinator config for queue %s: invalid [sdk]
coordinator config",
+ queue,
+ exc_info=True,
+ )
+ return None
+
+ def _coordinator_pod_template_file(self, extra: dict[str, Any]) -> str |
None:
+ """
+ Return the pod template declared in a coordinator's *extra* mapping,
if any.
+
+ Lets a queue routed to a non-Python coordinator (via ``[sdk]
+ queue_to_coordinator``) launch its worker pod from a
coordinator-specific
+ template — for example an image carrying the JVM for a Java
coordinator.
+ """
+ return extra.get("pod_template_file")
+
+ def _coordinator_kube_image(self, extra: dict[str, Any]) -> str | None:
+ """
+ Return the worker base image declared in a coordinator's *extra*
mapping, if any.
+
+ The base container image is never taken from a pod template; it comes
+ from ``kube_image``
(``worker_container_repository:worker_container_tag``)
+ or a per-task ``pod_override``. A coordinator may declare its own
+ ``worker_container_repository`` and ``worker_container_tag`` in
``extra``
+ (e.g. a JRE-bearing image for a Java coordinator); both are required to
+ compose an override, otherwise the executor default applies.
+ """
+ if (repo := extra.get("worker_container_repository")) and (tag :=
extra.get("worker_container_tag")):
+ return f"{repo}:{tag}"
+ return None
+
def execute_async(
self,
key: TaskInstanceKey,
@@ -225,8 +279,34 @@ class KubernetesExecutor(BaseExecutor):
pod_template_file = executor_config.get("pod_template_file", None)
else:
pod_template_file = None
+
+ coordinator_kube_image: str | None = None
+ if (coordinator_extra := self._coordinator_extra(queue)) is not None:
+ # A coordinator-level pod_template wins (e.g. a JVM image for
JavaCoordinator)
+ coordinator_pod_template_file =
self._coordinator_pod_template_file(coordinator_extra)
+ if coordinator_pod_template_file is not None:
+ self.log.debug(
+ "Using coordinator-declared pod template %s for task %s in
queue %s",
+ coordinator_pod_template_file,
+ key,
+ queue,
+ )
+ pod_template_file = coordinator_pod_template_file
+
+ # The base image is not carried by a pod template, so a
coordinator routes
+ # its worker base image separately (e.g. a JRE image for a Java
queue).
+ if (coordinator_kube_image :=
self._coordinator_kube_image(coordinator_extra)) is not None:
+ self.log.debug(
+ "Using coordinator-declared base image %s for task %s in
queue %s",
+ coordinator_kube_image,
+ key,
+ queue,
+ )
+
self.event_buffer[key] = (TaskInstanceState.QUEUED,
self.scheduler_job_id)
- self.task_queue.put(KubernetesJob(key, command, kube_executor_config,
pod_template_file))
+ self.task_queue.put(
+ KubernetesJob(key, command, kube_executor_config,
pod_template_file, coordinator_kube_image)
+ )
def queue_workload(self, workload: workloads.All, session: Session | None)
-> None:
from airflow.executors import workloads
diff --git
a/providers/cncf/kubernetes/src/airflow/providers/cncf/kubernetes/executors/kubernetes_executor_types.py
b/providers/cncf/kubernetes/src/airflow/providers/cncf/kubernetes/executors/kubernetes_executor_types.py
index f8e03f1f04c..45d2ea301f7 100644
---
a/providers/cncf/kubernetes/src/airflow/providers/cncf/kubernetes/executors/kubernetes_executor_types.py
+++
b/providers/cncf/kubernetes/src/airflow/providers/cncf/kubernetes/executors/kubernetes_executor_types.py
@@ -75,6 +75,7 @@ class KubernetesJob(NamedTuple):
command: Sequence[str]
kube_executor_config: Any
pod_template_file: str | None
+ kube_image: str | None = None
ALL_NAMESPACES = "ALL_NAMESPACES"
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 af719ada9e4..787ea73237f 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
@@ -558,6 +558,7 @@ class AirflowKubernetesScheduler(LoggingMixin):
command = next_job.command
kube_executor_config = next_job.kube_executor_config
pod_template_file = next_job.pod_template_file
+ kube_image = next_job.kube_image or self.kube_config.kube_image
dag_id, task_id, run_id, try_number, map_index = key
if len(command) == 1:
@@ -586,7 +587,7 @@ class AirflowKubernetesScheduler(LoggingMixin):
pod_id=create_unique_id(dag_id, task_id),
dag_id=dag_id,
task_id=task_id,
- kube_image=self.kube_config.kube_image,
+ kube_image=kube_image,
try_number=try_number,
map_index=map_index,
date=None,
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 bc1c2a97f55..65f5820482b 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
@@ -39,6 +39,7 @@ from
airflow.providers.cncf.kubernetes.executors.kubernetes_executor import (
)
from airflow.providers.cncf.kubernetes.executors.kubernetes_executor_types
import (
ADOPTED,
+ KubernetesJob,
KubernetesResults,
KubernetesWatch,
)
@@ -66,7 +67,11 @@ except ImportError:
from airflow.utils.state import State, TaskInstanceState
from tests_common.test_utils.config import conf_vars
-from tests_common.test_utils.version_compat import AIRFLOW_V_3_0_PLUS,
AIRFLOW_V_3_2_PLUS
+from tests_common.test_utils.version_compat import (
+ AIRFLOW_V_3_0_PLUS,
+ AIRFLOW_V_3_2_PLUS,
+ AIRFLOW_V_3_3_PLUS,
+)
try:
# Check whether a module-level function from stats is importable.
@@ -863,11 +868,10 @@ class TestKubernetesExecutor:
assert not executor.task_queue.empty()
task = executor.task_queue.get_nowait()
- _, _, expected_executor_config, expected_pod_template_file =
task
executor.task_queue.task_done()
# Test that the correct values have been put to queue
- assert expected_executor_config.metadata.labels == {"release":
"stable"}
- assert expected_pod_template_file == executor_template_file
+ assert task.kube_executor_config.metadata.labels ==
{"release": "stable"}
+ assert task.pod_template_file == executor_template_file
self.kubernetes_executor.kube_scheduler.run_next(task)
mock_run_pod_async.assert_called_once_with(
@@ -915,6 +919,192 @@ class TestKubernetesExecutor:
finally:
executor.end()
+ @pytest.mark.skipif(not AIRFLOW_V_3_3_PLUS, reason="The coordinator
interface only support since 3.3+")
+ @pytest.mark.parametrize(
+ ("coordinator_extra", "executor_config", "expected_template"),
+ [
+ pytest.param(
+ {"pod_template_file": "/coord/java.yaml"},
+ None,
+ "/coord/java.yaml",
+ id="coordinator-template-used",
+ ),
+ pytest.param(
+ {"pod_template_file": "/coord/java.yaml"},
+ {"pod_template_file": "/from/executor_config.yaml"},
+ "/coord/java.yaml",
+ id="coordinator-template-wins",
+ ),
+ pytest.param(
+ None,
+ {"pod_template_file": "/from/executor_config.yaml"},
+ "/from/executor_config.yaml",
+ id="executor-config-used-without-coordinator",
+ ),
+ ],
+ )
+
@mock.patch("airflow.providers.cncf.kubernetes.executors.kubernetes_executor_utils.KubernetesJobWatcher")
+
@mock.patch("airflow.providers.cncf.kubernetes.kube_client.get_kube_client")
+ @mock.patch.object(KubernetesExecutor, "_coordinator_extra")
+ def test_coordinator_pod_template_file_used_for_queue(
+ self,
+ mock_coordinator_extra,
+ mock_get_kube_client,
+ mock_kubernetes_job_watcher,
+ coordinator_extra,
+ executor_config,
+ expected_template,
+ ):
+ """A queue coordinator's template overrides executor_config; without a
coordinator, executor_config is used."""
+ mock_coordinator_extra.return_value = coordinator_extra
+ executor = self.kubernetes_executor
+ executor.start()
+ try:
+ executor.execute_async(
+ key=TaskInstanceKey("dag", "task", "run_id", 1, -1),
+ queue="java",
+ command=["airflow", "tasks", "run", "true", "some_parameter"],
+ executor_config=executor_config,
+ )
+ assert not executor.task_queue.empty()
+ queued_job = executor.task_queue.get_nowait()
+ executor.task_queue.task_done()
+ assert queued_job.pod_template_file == expected_template
+ finally:
+ executor.end()
+
+ @pytest.mark.skipif(not AIRFLOW_V_3_3_PLUS, reason="The coordinator
interface only support since 3.3+")
+ @pytest.mark.parametrize(
+ ("extra", "expected"),
+ [
+ pytest.param({"pod_template_file": "/coord/go.yaml"},
"/coord/go.yaml", id="template-in-extra"),
+ pytest.param({"other": "value"}, None,
id="extra-without-template"),
+ ],
+ )
+ def test_coordinator_pod_template_file_reads_extra(self, extra, expected):
+ """The template is read straight from the coordinator ``extra``
mapping passed in."""
+ assert self.kubernetes_executor._coordinator_pod_template_file(extra)
== expected
+
+ @pytest.mark.skipif(not AIRFLOW_V_3_3_PLUS, reason="The coordinator
interface only support since 3.3+")
+
@mock.patch("airflow.providers.cncf.kubernetes.executors.kubernetes_executor_utils.KubernetesJobWatcher")
+
@mock.patch("airflow.providers.cncf.kubernetes.kube_client.get_kube_client")
+ @mock.patch.object(
+ KubernetesExecutor,
+ "_coordinator_extra",
+ return_value={"worker_container_repository": "repo/java",
"worker_container_tag": "1"},
+ )
+ def test_coordinator_kube_image_carried_on_job(
+ self,
+ mock_coordinator_extra,
+ mock_get_kube_client,
+ mock_kubernetes_job_watcher,
+ ):
+ """A coordinator base image resolved by queue rides on the queued
job."""
+ executor = self.kubernetes_executor
+ executor.start()
+ try:
+ executor.execute_async(
+ key=TaskInstanceKey("dag", "task", "run_id", 1, -1),
+ queue="java",
+ command=["airflow", "tasks", "run", "true", "some_parameter"],
+ )
+ queued_job = executor.task_queue.get_nowait()
+ executor.task_queue.task_done()
+ assert queued_job.kube_image == "repo/java:1"
+ finally:
+ executor.end()
+
+ @pytest.mark.skipif(not AIRFLOW_V_3_3_PLUS, reason="The coordinator
interface only support since 3.3+")
+ @pytest.mark.parametrize(
+ ("extra", "expected"),
+ [
+ pytest.param(
+ {"worker_container_repository": "repo/java",
"worker_container_tag": "17"},
+ "repo/java:17",
+ id="repository-and-tag",
+ ),
+ pytest.param({"worker_container_repository": "repo/java"}, None,
id="repository-only"),
+ pytest.param({"worker_container_tag": "17"}, None, id="tag-only"),
+ pytest.param({"other": "value"}, None, id="extra-without-image"),
+ ],
+ )
+ def test_coordinator_kube_image_reads_extra(self, extra, expected):
+ """The base image is composed straight from the coordinator ``extra``
mapping passed in."""
+ assert self.kubernetes_executor._coordinator_kube_image(extra) ==
expected
+
+ @pytest.mark.skipif(not AIRFLOW_V_3_3_PLUS, reason="The coordinator
interface only support since 3.3+")
+ def test_coordinator_extra_skips_lookup_without_queue(self):
+ """No queue means no coordinator lookup (and no Task SDK import)."""
+ with
mock.patch("airflow.sdk.execution_time.coordinator.get_coordinator_manager") as
mock_get_manager:
+ assert self.kubernetes_executor._coordinator_extra(None) is None
+ mock_get_manager.assert_not_called()
+
+ @pytest.mark.skipif(not AIRFLOW_V_3_3_PLUS, reason="The coordinator
interface only support since 3.3+")
+ def test_coordinator_extra_returns_none_on_old_task_sdk(self):
+ """Pre-3.3 Task SDKs lack get_coordinator_manager; the import error
falls back to None."""
+ with mock.patch.dict("sys.modules",
{"airflow.sdk.execution_time.coordinator": None}):
+ assert self.kubernetes_executor._coordinator_extra("java") is None
+
+ @pytest.mark.skipif(not AIRFLOW_V_3_3_PLUS, reason="The coordinator
interface only support since 3.3+")
+ @pytest.mark.parametrize("exc_type", ["airflow_config_exception",
"value_error"])
+ def test_coordinator_extra_falls_back_on_invalid_config(self, exc_type):
+ """A malformed ``[sdk]`` coordinator config must degrade gracefully,
not crash the scheduler."""
+ if exc_type == "airflow_config_exception":
+ from airflow.sdk.exceptions import AirflowConfigException
+
+ exc = AirflowConfigException("invalid json")
+ else:
+ exc = ValueError("invalid coordinator key")
+ with
mock.patch("airflow.sdk.execution_time.coordinator.get_coordinator_manager") as
mock_get_manager:
+ mock_get_manager.return_value.extra_for_queue.side_effect = exc
+ assert self.kubernetes_executor._coordinator_extra("java") is None
+
+ @pytest.mark.skipif(
+ AirflowKubernetesScheduler is None, reason="kubernetes python package
is not installed"
+ )
+ @pytest.mark.parametrize(
+ ("job_image", "use_default"),
+ [
+ pytest.param("repo/java:17", False, id="job-image-wins"),
+ pytest.param(None, True, id="falls-back-to-kube_config"),
+ ],
+ )
+ @mock.patch(
+
"airflow.providers.cncf.kubernetes.executors.kubernetes_executor_utils.AirflowKubernetesScheduler.run_pod_async"
+ )
+
@mock.patch("airflow.providers.cncf.kubernetes.executors.kubernetes_executor_utils.PodGenerator")
+ @mock.patch(
+
"airflow.providers.cncf.kubernetes.executors.kubernetes_executor_utils.get_base_pod_from_template"
+ )
+
@mock.patch("airflow.providers.cncf.kubernetes.kube_client.get_kube_client")
+ def test_run_next_applies_job_kube_image(
+ self,
+ mock_get_kube_client,
+ mock_get_base_pod,
+ mock_pod_generator,
+ mock_run_pod_async,
+ job_image,
+ use_default,
+ ):
+ """``run_next`` uses the job's coordinator image, falling back to the
kube_config default."""
+ executor = self.kubernetes_executor
+ executor.start()
+ try:
+ scheduler = executor.kube_scheduler
+ scheduler.run_next(
+ KubernetesJob(
+ key=TaskInstanceKey("dag", "task", "run_id", 1, -1),
+ command=["airflow", "tasks", "run", "true",
"some_parameter"],
+ kube_executor_config=None,
+ pod_template_file=None,
+ kube_image=job_image,
+ )
+ )
+ expected = scheduler.kube_config.kube_image if use_default else
job_image
+ assert
mock_pod_generator.construct_pod.call_args.kwargs["kube_image"] == expected
+ finally:
+ executor.end()
+
@pytest.mark.db_test
@mock.patch("airflow.providers.cncf.kubernetes.executors.kubernetes_executor_utils.KubernetesJobWatcher")
@mock.patch("airflow.providers.cncf.kubernetes.kube_client.get_kube_client")