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

eladkal 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 0dfb55358c4 Requeue KubernetesExecutor tasks whose pod failed before 
execution started (#69058)
0dfb55358c4 is described below

commit 0dfb55358c44c9099576dc66cd1123c601d8b12b
Author: Sean Muth <[email protected]>
AuthorDate: Wed Jul 15 07:39:13 2026 -0500

    Requeue KubernetesExecutor tasks whose pod failed before execution started 
(#69058)
    
    * Requeue KubernetesExecutor tasks whose pod failed before execution started
    
    When a worker pod is destroyed before the task process starts (node drain, 
autoscaler scale-down, node boot race, transient image pull failure), the task 
instance is still queued and no task code has run. Reporting this to the 
scheduler as a normal failure consumes a user-configured retry and raises a 
misleading failure alert for work that never executed. The executor already has 
the signal to tell this apart from an execution failure, so it now 
transparently requeues the pod without  [...]
    
    * Fix KubernetesExecutor pre-execution requeue tests hanging in CI
    
    The tests replaced executor.task_queue with a MagicMock, so the 
executor.end() teardown looped forever on get_nowait() instead of raising 
Empty, hitting the 60s CI timeout. Assert the requeue via observable executor 
state instead of mocking the queue, and pass a valid executor_config to the 
stash test so execute_async does not bail before recording the job.
    
    * Address review feedback on KubernetesExecutor pre-execution requeue
    
    Kubernetes can emit several Failed events for a single worker pod (for 
example two MODIFIED then DELETED on SIGTERM); each one previously triggered 
another requeue, creating duplicate pods and over-counting attempts. Track the 
job spec, requeue count, and the pod a requeue was last issued for together in 
one per-key object so duplicate events for the same pod are ignored and the 
requeue count no longer leaks when a pod is adopted. Skip the task-instance 
state lookup when requeues are  [...]
    
    * Document that KubernetesExecutor pod_launch_attempts is ephemeral across 
scheduler restarts
    
    Clarify that the in-memory requeue state is intentionally not persisted and 
that the dead-scheduler adoption path is a safe no-op for it, so a future 
reader doesn't mistake the lack of recovery for a bug.
    
    * Fix CI: skip pre-execution DB lookup for untracked pods; whitelist 
'requeues'
    
    The pre-execution-failure check ran a metadata-db query for every failed 
pod, breaking the test_pod_failure_logging_* K8s system tests (no task_instance 
table) and needlessly querying for adopted/finalized pods. Gate the lookup on 
an existing pod_launch_attempts entry so only pods this executor launched and 
still tracks are considered. Also add the plural 'requeues' to the docs 
spelling wordlist (singular forms were already present).
    
    * Fix mypy: narrow task_queue before requeuing pod launch job
    
    self.task_queue is typed Queue | None; guard the requeue put() with a 
TYPE_CHECKING assert (matching the other call sites in this file) so mypy 
narrows it without tripping ruff's S101 on a runtime assert.
    
    * Set version_added to 10.20.0 for new KubernetesExecutor pod-launch configs
---
 docs/spelling_wordlist.txt                         |   1 +
 providers/cncf/kubernetes/docs/changelog.rst       |  12 +
 providers/cncf/kubernetes/provider.yaml            |  34 +++
 .../kubernetes/executors/kubernetes_executor.py    | 139 +++++++++-
 .../providers/cncf/kubernetes/get_provider_info.py |  14 +
 .../executors/test_kubernetes_executor.py          | 290 +++++++++++++++++++++
 6 files changed, 479 insertions(+), 11 deletions(-)

diff --git a/docs/spelling_wordlist.txt b/docs/spelling_wordlist.txt
index 062642515b3..b304f8ba3d6 100644
--- a/docs/spelling_wordlist.txt
+++ b/docs/spelling_wordlist.txt
@@ -1420,6 +1420,7 @@ req
 reqs
 requeue
 requeued
+requeues
 reserialize
 resetdb
 resizable
diff --git a/providers/cncf/kubernetes/docs/changelog.rst 
b/providers/cncf/kubernetes/docs/changelog.rst
index fc8cab31de6..63659c31690 100644
--- a/providers/cncf/kubernetes/docs/changelog.rst
+++ b/providers/cncf/kubernetes/docs/changelog.rst
@@ -27,6 +27,18 @@
 Changelog
 ---------
 
+.. note::
+   The ``KubernetesExecutor`` now transparently requeues a worker pod that 
fails *before* the
+   task process starts (node drain, autoscaler scale-down, node boot race, 
transient image pull
+   failure, etc.) instead of failing the task on the first pod failure. This 
is a change in
+   default behavior, controlled by the new ``[kubernetes_executor] 
pod_launch_failure_retries``
+   option (default ``1``); requeues do not consume a task-level retry. Set it 
to ``0`` to restore
+   the previous behavior of failing immediately. Avoid ``-1`` (unlimited) with 
a pod that fails on
+   every launch, as the failed pods are not cleaned up under the default
+   ``delete_worker_pods_on_failure = False`` and will accumulate. The companion
+   ``pod_launch_failure_excluded_container_reasons`` option (default 
``Error``) lists container
+   reasons that are excluded from the requeue path.
+
 10.19.0
 .......
 
diff --git a/providers/cncf/kubernetes/provider.yaml 
b/providers/cncf/kubernetes/provider.yaml
index 5d944119163..584f015dcda 100644
--- a/providers/cncf/kubernetes/provider.yaml
+++ b/providers/cncf/kubernetes/provider.yaml
@@ -468,6 +468,40 @@ config:
         type: integer
         example: ~
         default: "0"
+      pod_launch_failure_retries:
+        description: |
+          The number of times the executor will transparently requeue a task 
whose worker pod
+          failed before the task process started running (for example a node 
drain, autoscaler
+          scale-down, node boot race, or transient image pull failure). The 
task instance is
+          still in ``queued`` state in these cases, meaning no task code ran, 
so requeuing does
+          not consume a task-level retry.
+
+          This changes the previous default behavior: such tasks are now 
requeued once before
+          failing instead of failing on the first pod failure. Set this to 0 
to restore the
+          previous behavior (fail immediately, no requeue).
+
+          Use -1 for unlimited requeues, but with caution: a pod that fails on 
every launch
+          (for example a misconfigured image that can never be pulled) will be 
requeued forever,
+          and with the default ``delete_worker_pods_on_failure = False`` the 
failed pods are not
+          cleaned up, so they accumulate.
+        version_added: 10.20.0
+        type: integer
+        example: ~
+        default: "1"
+      pod_launch_failure_excluded_container_reasons:
+        description: |
+          Comma-separated list of container termination reasons that are 
excluded from the
+          ``pod_launch_failure_retries`` requeue path even when the task 
instance is still in
+          ``queued`` state. Pods that fail with an excluded reason consume a 
normal task retry
+          instead of being transparently requeued. The default ``Error`` 
covers the case where
+          the container started executing but the worker process exited before 
writing
+          ``running`` to the database, which is most likely an 
Airflow-specific startup error
+          rather than a transient infrastructure event. Set to an empty value 
to requeue these
+          cases as well.
+        version_added: 10.20.0
+        type: string
+        example: ~
+        default: "Error"
 
 executors:
   - 
airflow.providers.cncf.kubernetes.executors.kubernetes_executor.KubernetesExecutor
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 46137915afb..ba0355504b1 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
@@ -31,6 +31,7 @@ import multiprocessing
 import time
 from collections import Counter, defaultdict
 from contextlib import suppress
+from dataclasses import dataclass
 from datetime import datetime, timedelta
 from queue import Empty, Queue
 from typing import TYPE_CHECKING, Any
@@ -45,6 +46,7 @@ from airflow.providers.cncf.kubernetes.exceptions import 
PodMutationHookExceptio
 from airflow.providers.cncf.kubernetes.executors.kubernetes_executor_types 
import (
     ADOPTED,
     POD_EXECUTOR_DONE_KEY,
+    FailureDetails,
     KubernetesJob,
     KubernetesResults,
 )
@@ -75,6 +77,20 @@ if TYPE_CHECKING:
     )
 
 
+@dataclass
+class _PodLaunchAttempt:
+    """
+    Executor-side requeue state for a task whose worker pod may fail before 
the task process starts.
+
+    ``requeued_for_pod`` records the pod a requeue was last issued for, so the 
duplicate
+    ``Failed`` events Kubernetes can emit for a single pod don't each trigger 
another requeue.
+    """
+
+    job: KubernetesJob
+    attempts: int = 0
+    requeued_for_pod: str | None = None
+
+
 class KubernetesExecutor(BaseExecutor):
     """Executor for Kubernetes."""
 
@@ -120,6 +136,24 @@ class KubernetesExecutor(BaseExecutor):
         self.task_publish_max_retries = self.conf.getint(
             "kubernetes_executor", "task_publish_max_retries", fallback=0
         )
+        self.pod_launch_failure_max_retries = self.conf.getint(
+            "kubernetes_executor", "pod_launch_failure_retries", fallback=1
+        )
+        excluded_reasons = self.conf.get(
+            "kubernetes_executor", 
"pod_launch_failure_excluded_container_reasons", fallback="Error"
+        )
+        self.pod_launch_failure_excluded_container_reasons = frozenset(
+            reason.strip() for reason in excluded_reasons.split(",") if 
reason.strip()
+        )
+        # Per-key state for requeuing pods that fail before the task process 
starts (job spec,
+        # requeue count, and the pod a requeue was last issued for), so the 
failure is never
+        # observed by the scheduler and no task-level retry is consumed.
+        # Intentionally in-memory and not persisted (like 
task_publish_retries): if this scheduler
+        # dies the state is lost, and adoption by another scheduler is a safe 
no-op for it -- an
+        # adopted pod has no entry here, so a pre-execution failure falls 
through to a normal fail
+        # instead of requeuing. The orphaned task instance itself is still 
recovered by the
+        # scheduler's adopt_or_reset_orphaned_tasks(), which re-queues it with 
a fresh attempt.
+        self.pod_launch_attempts: dict[TaskInstanceKey, _PodLaunchAttempt] = {}
         self.completed: dict[tuple[str, str], KubernetesResults] = {}
         self.create_pods_after: datetime | None = None
 
@@ -319,9 +353,9 @@ class KubernetesExecutor(BaseExecutor):
                 )
 
         self.event_buffer[key] = (TaskInstanceState.QUEUED, 
self.scheduler_job_id)
-        self.task_queue.put(
-            KubernetesJob(key, command, kube_executor_config, 
pod_template_file, coordinator_kube_image)
-        )
+        job = KubernetesJob(key, command, kube_executor_config, 
pod_template_file, coordinator_kube_image)
+        self.pod_launch_attempts[key] = _PodLaunchAttempt(job=job)
+        self.task_queue.put(job)
 
     def queue_workload(self, workload: workloads.All, session: Session | None) 
-> None:
         from airflow.executors import workloads
@@ -560,6 +594,7 @@ class KubernetesExecutor(BaseExecutor):
         if state == ADOPTED:
             # When the task pod is adopted by another executor,
             # then remove the task from the current executor running queue.
+            self.pod_launch_attempts.pop(key, None)
             try:
                 self.running.remove(key)
             except KeyError:
@@ -583,6 +618,54 @@ class KubernetesExecutor(BaseExecutor):
             self.kube_scheduler.patch_pod_executor_done(pod_name=pod_name, 
namespace=namespace)
             self.log.info("Patched pod %s in namespace %s to mark it as done", 
key, namespace)
 
+        # Only pods this executor launched and is still tracking can be 
requeued; checking the
+        # in-memory attempt first avoids a metadata-db lookup for adopted or 
already-finalized pods.
+        attempt = self.pod_launch_attempts.get(key)
+        if (
+            attempt is not None
+            and state == TaskInstanceState.FAILED
+            and self.pod_launch_failure_max_retries != 0
+            and self._is_pre_execution_failure(
+                state,
+                self._get_task_instance_state(key, session=session),
+                failure_details,
+                self.pod_launch_failure_excluded_container_reasons,
+            )
+        ):
+            if attempt.requeued_for_pod == pod_name:
+                # Kubernetes can emit several Failed events for one pod; we 
already requeued
+                # for this one, so ignore the duplicates instead of requeuing 
again.
+                self.log.debug(
+                    "Ignoring duplicate pre-execution failure for 
already-requeued pod %s/%s",
+                    namespace,
+                    pod_name,
+                )
+                return
+            if (
+                self.pod_launch_failure_max_retries == -1
+                or attempt.attempts < self.pod_launch_failure_max_retries
+            ):
+                attempt.attempts += 1
+                attempt.requeued_for_pod = pod_name
+                self.log.warning(
+                    "[Try %s of %s] Pod %s/%s for task %s failed before the 
task process started "
+                    "(container_reason: %s). Requeuing without consuming a 
task retry.",
+                    attempt.attempts,
+                    self.pod_launch_failure_max_retries,
+                    namespace,
+                    pod_name,
+                    key,
+                    failure_details.get("container_reason") if failure_details 
else None,
+                )
+                # Leave the key in self.running and do not write to 
event_buffer: the scheduler
+                # never observes this failure, so no task-level retry is 
consumed.
+                if TYPE_CHECKING:
+                    assert self.task_queue
+                self.task_queue.put(attempt.job)
+                return
+
+        self.pod_launch_attempts.pop(key, None)
+
         try:
             self.running.remove(key)
         except KeyError:
@@ -591,17 +674,51 @@ class KubernetesExecutor(BaseExecutor):
 
         # If we don't have a TI state, look it up from the db. event_buffer 
expects the TI state
         if state is None:
-            from airflow.models.taskinstance import TaskInstance
-
-            filter_for_tis = TaskInstance.filter_for_tis([key])
-            if filter_for_tis is not None:
-                state = 
session.scalar(select(TaskInstance.state).where(filter_for_tis))
-            else:
-                state = None
-            state = TaskInstanceState(state) if state else None
+            state = self._get_task_instance_state(key, session=session)
 
         self.event_buffer[key] = state, termination_reason
 
+    def _get_task_instance_state(self, key: TaskInstanceKey, *, session: 
Session) -> TaskInstanceState | None:
+        """Look up the current task instance state from the metadata 
database."""
+        from airflow.models.taskinstance import TaskInstance
+
+        filter_for_tis = TaskInstance.filter_for_tis([key])
+        if filter_for_tis is None:
+            return None
+        db_state = 
session.scalar(select(TaskInstance.state).where(filter_for_tis))
+        return TaskInstanceState(db_state) if db_state else None
+
+    @staticmethod
+    def _is_pre_execution_failure(
+        state: TaskInstanceState | str | None,
+        ti_state: TaskInstanceState | None,
+        failure_details: FailureDetails | None,
+        excluded_container_reasons: frozenset[str],
+    ) -> bool:
+        """
+        Return ``True`` if a failed pod's task process never started running.
+
+        Both conditions are required:
+
+        - ``state`` is ``FAILED``: the pod actually terminated.
+        - ``ti_state`` is ``QUEUED``: the task instance never transitioned to 
``running``, so no
+          task code ran. This is the authoritative signal and holds regardless 
of the specific
+          container failure reason (node drain, autoscaler scale-down, 
transient image pull
+          error, deferrable resume pod killed before ``execute_complete`` 
started, etc.).
+
+        Pods whose ``container_reason`` is in ``excluded_container_reasons`` 
are not treated as
+        pre-execution failures. The default exclusion of ``Error`` covers a 
container that
+        started executing but whose worker process exited before writing 
``running`` to the
+        database, which is most likely an Airflow-specific startup error.
+        """
+        if state != TaskInstanceState.FAILED or ti_state != 
TaskInstanceState.QUEUED:
+            return False
+        if failure_details:
+            container_reason = failure_details.get("container_reason")
+            if container_reason and container_reason in 
excluded_container_reasons:
+                return False
+        return True
+
     def _get_pod_namespace(self, ti: TaskInstance):
         pod_override = (ti.executor_config or {}).get("pod_override")
         namespace = None
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 e0cfbecf3b2..73b9f1ea814 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
@@ -309,6 +309,20 @@ def get_provider_info():
                         "example": None,
                         "default": "0",
                     },
+                    "pod_launch_failure_retries": {
+                        "description": "The number of times the executor will 
transparently requeue a task whose worker pod\nfailed before the task process 
started running (for example a node drain, autoscaler\nscale-down, node boot 
race, or transient image pull failure). The task instance is\nstill in 
``queued`` state in these cases, meaning no task code ran, so requeuing 
does\nnot consume a task-level retry.\n\nThis changes the previous default 
behavior: such tasks are now requeued onc [...]
+                        "version_added": "10.20.0",
+                        "type": "integer",
+                        "example": None,
+                        "default": "1",
+                    },
+                    "pod_launch_failure_excluded_container_reasons": {
+                        "description": "Comma-separated list of container 
termination reasons that are excluded from the\n``pod_launch_failure_retries`` 
requeue path even when the task instance is still in\n``queued`` state. Pods 
that fail with an excluded reason consume a normal task retry\ninstead of being 
transparently requeued. The default ``Error`` covers the case where\nthe 
container started executing but the worker process exited before 
writing\n``running`` to the database, which  [...]
+                        "version_added": "10.20.0",
+                        "type": "string",
+                        "example": None,
+                        "default": "Error",
+                    },
                 },
             },
         },
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 285b263b064..e5716e30ffc 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
@@ -36,6 +36,7 @@ from airflow.providers.cncf.kubernetes import pod_generator
 from airflow.providers.cncf.kubernetes.executors.kubernetes_executor import (
     KubernetesExecutor,
     PodReconciliationError,
+    _PodLaunchAttempt,
 )
 from airflow.providers.cncf.kubernetes.executors.kubernetes_executor_types 
import (
     ADOPTED,
@@ -1551,6 +1552,295 @@ class TestKubernetesExecutor:
         finally:
             executor.end()
 
+    @pytest.mark.parametrize(
+        ("state", "ti_state", "failure_details", "expected"),
+        [
+            pytest.param(
+                TaskInstanceState.FAILED,
+                TaskInstanceState.QUEUED,
+                None,
+                True,
+                id="failed-queued-no-details",
+            ),
+            pytest.param(
+                TaskInstanceState.FAILED,
+                TaskInstanceState.QUEUED,
+                {"container_reason": "ContainerStatusUnknown"},
+                True,
+                id="failed-queued-node-killed",
+            ),
+            pytest.param(
+                TaskInstanceState.FAILED,
+                TaskInstanceState.QUEUED,
+                {"container_reason": "ImagePullBackOff"},
+                True,
+                id="failed-queued-transient-image-pull",
+            ),
+            pytest.param(
+                TaskInstanceState.FAILED,
+                TaskInstanceState.QUEUED,
+                {"container_reason": "Error"},
+                False,
+                id="failed-queued-excluded-error-reason",
+            ),
+            pytest.param(
+                TaskInstanceState.FAILED,
+                TaskInstanceState.RUNNING,
+                {"container_reason": "ContainerStatusUnknown"},
+                False,
+                id="failed-but-task-was-running",
+            ),
+            pytest.param(
+                TaskInstanceState.SUCCESS,
+                TaskInstanceState.QUEUED,
+                None,
+                False,
+                id="not-a-failed-pod",
+            ),
+            pytest.param(
+                TaskInstanceState.FAILED,
+                None,
+                None,
+                False,
+                id="ti-state-missing",
+            ),
+        ],
+    )
+    def test_is_pre_execution_failure(self, state, ti_state, failure_details, 
expected):
+        assert (
+            KubernetesExecutor._is_pre_execution_failure(
+                state, ti_state, failure_details, frozenset({"Error"})
+            )
+            is expected
+        )
+
+    @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")
+    def test_change_state_pre_execution_failure_requeues(
+        self, mock_get_kube_client, mock_kubernetes_job_watcher, 
create_task_instance
+    ):
+        """A pod that fails while the TI is still queued is requeued without 
reporting a failure."""
+        executor = self.kubernetes_executor
+        executor.pod_launch_failure_max_retries = 1
+        executor.start()
+        try:
+            ti = create_task_instance(state=TaskInstanceState.QUEUED)
+            key = ti.key
+            job = KubernetesJob(key, ["airflow", "tasks", "run"], None, None)
+            executor.running = {key}
+            executor.pod_launch_attempts = {key: _PodLaunchAttempt(job=job)}
+            results = KubernetesResults(
+                key,
+                State.FAILED,
+                "pod_name",
+                "default",
+                "resource_version",
+                {"container_reason": "ContainerStatusUnknown", "exit_code": 
137},
+            )
+            executor._change_state(results)
+
+            # Requeued (job re-put on the queue): the key stays in running, no
+            # failure is reported, and the attempt is recorded. These together
+            # are reached only via the requeue branch.
+            assert executor.pod_launch_attempts[key].attempts == 1
+            assert executor.pod_launch_attempts[key].requeued_for_pod == 
"pod_name"
+            assert key in executor.running
+            assert key not in executor.event_buffer
+        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")
+    def test_change_state_pre_execution_failure_exhausts_retries(
+        self, mock_get_kube_client, mock_kubernetes_job_watcher, 
create_task_instance
+    ):
+        """Once the requeue budget is spent, the task is failed normally."""
+        executor = self.kubernetes_executor
+        executor.pod_launch_failure_max_retries = 1
+        executor.start()
+        try:
+            ti = create_task_instance(state=TaskInstanceState.QUEUED)
+            key = ti.key
+            job = KubernetesJob(key, ["airflow", "tasks", "run"], None, None)
+            executor.running = {key}
+            # Already requeued once (for a different pod); budget is now spent.
+            executor.pod_launch_attempts = {
+                key: _PodLaunchAttempt(job=job, attempts=1, 
requeued_for_pod="earlier_pod")
+            }
+            results = KubernetesResults(
+                key,
+                State.FAILED,
+                "pod_name",
+                "default",
+                "resource_version",
+                {"container_reason": "ContainerStatusUnknown"},
+            )
+            executor._change_state(results)
+
+            assert executor.event_buffer[key][0] == State.FAILED
+            assert key not in executor.running
+            assert key not in executor.pod_launch_attempts
+        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")
+    def test_change_state_pre_execution_failure_excluded_reason(
+        self, mock_get_kube_client, mock_kubernetes_job_watcher, 
create_task_instance
+    ):
+        """An excluded container reason consumes a normal task retry instead 
of requeuing."""
+        executor = self.kubernetes_executor
+        executor.start()
+        try:
+            ti = create_task_instance(state=TaskInstanceState.QUEUED)
+            key = ti.key
+            executor.running = {key}
+            executor.pod_launch_attempts = {
+                key: _PodLaunchAttempt(job=KubernetesJob(key, ["airflow"], 
None, None))
+            }
+            results = KubernetesResults(
+                key,
+                State.FAILED,
+                "pod_name",
+                "default",
+                "resource_version",
+                {"container_reason": "Error", "exit_code": 1},
+            )
+            executor._change_state(results)
+
+            assert executor.event_buffer[key][0] == State.FAILED
+            assert key not in executor.running
+        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")
+    def test_change_state_pre_execution_failure_without_job_spec(
+        self, mock_get_kube_client, mock_kubernetes_job_watcher, 
create_task_instance
+    ):
+        """Without a retained job spec the task cannot be requeued and is 
failed normally."""
+        executor = self.kubernetes_executor
+        executor.start()
+        try:
+            ti = create_task_instance(state=TaskInstanceState.QUEUED)
+            key = ti.key
+            executor.running = {key}
+            executor.pod_launch_attempts = {}
+            results = KubernetesResults(
+                key,
+                State.FAILED,
+                "pod_name",
+                "default",
+                "resource_version",
+                {"container_reason": "ContainerStatusUnknown"},
+            )
+            executor._change_state(results)
+
+            assert executor.event_buffer[key][0] == State.FAILED
+            assert key not in executor.running
+        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")
+    def test_change_state_pre_execution_failure_disabled_skips_lookup(
+        self, mock_get_kube_client, mock_kubernetes_job_watcher, 
create_task_instance
+    ):
+        """With pod_launch_failure_retries=0 the task fails immediately and 
the TI-state lookup is skipped."""
+        executor = self.kubernetes_executor
+        executor.pod_launch_failure_max_retries = 0
+        executor.start()
+        try:
+            ti = create_task_instance(state=TaskInstanceState.QUEUED)
+            key = ti.key
+            executor.running = {key}
+            executor.pod_launch_attempts = {
+                key: _PodLaunchAttempt(job=KubernetesJob(key, ["airflow"], 
None, None))
+            }
+            results = KubernetesResults(
+                key,
+                State.FAILED,
+                "pod_name",
+                "default",
+                "resource_version",
+                {"container_reason": "ContainerStatusUnknown"},
+            )
+            with mock.patch.object(executor, "_get_task_instance_state") as 
mock_lookup:
+                executor._change_state(results)
+
+            mock_lookup.assert_not_called()
+            assert executor.event_buffer[key][0] == State.FAILED
+            assert key not in executor.running
+        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")
+    def test_change_state_pre_execution_failure_dedupes_repeated_events(
+        self, mock_get_kube_client, mock_kubernetes_job_watcher, 
create_task_instance
+    ):
+        """Repeated Failed events for one pod requeue once; a new pod requeues 
again."""
+        executor = self.kubernetes_executor
+        executor.pod_launch_failure_max_retries = 5
+        executor.start()
+        try:
+            ti = create_task_instance(state=TaskInstanceState.QUEUED)
+            key = ti.key
+            job = KubernetesJob(key, ["airflow", "tasks", "run"], None, None)
+            executor.running = {key}
+            executor.pod_launch_attempts = {key: _PodLaunchAttempt(job=job)}
+
+            def _failed(pod_name):
+                return KubernetesResults(
+                    key,
+                    State.FAILED,
+                    pod_name,
+                    "default",
+                    "rv",
+                    {"container_reason": "ContainerStatusUnknown"},
+                )
+
+            # Three Failed events for the same pod -> a single requeue.
+            executor._change_state(_failed("pod_a"))
+            executor._change_state(_failed("pod_a"))
+            executor._change_state(_failed("pod_a"))
+            assert executor.pod_launch_attempts[key].attempts == 1
+            assert executor.pod_launch_attempts[key].requeued_for_pod == 
"pod_a"
+
+            # A failure of the requeued (distinct) pod requeues again.
+            executor._change_state(_failed("pod_b"))
+            assert executor.pod_launch_attempts[key].attempts == 2
+            assert executor.pod_launch_attempts[key].requeued_for_pod == 
"pod_b"
+            assert key in executor.running
+            assert key not in executor.event_buffer
+        finally:
+            executor.end()
+
+    
@mock.patch("airflow.providers.cncf.kubernetes.executors.kubernetes_executor_utils.KubernetesJobWatcher")
+    
@mock.patch("airflow.providers.cncf.kubernetes.kube_client.get_kube_client")
+    def test_execute_async_retains_job_spec(self, mock_get_kube_client, 
mock_kubernetes_job_watcher):
+        """execute_async stashes the job spec so a pre-execution failure can 
be requeued."""
+        executor = self.kubernetes_executor
+        executor.start()
+        try:
+            key = TaskInstanceKey("dag", "task", "run_id", 1, -1)
+            executor.execute_async(
+                key=key,
+                queue=None,
+                command=["airflow", "tasks", "run", "true", "some_parameter"],
+                executor_config=None,
+            )
+            assert key in executor.pod_launch_attempts
+            assert executor.pod_launch_attempts[key].job.key == key
+        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")

Reply via email to