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 eca48163048 Add `--min-completed-minutes` to `cleanup-pods` to prevent
KPO race condition (#70595)
eca48163048 is described below
commit eca48163048a46bba4a242e66096df3e34637f9e
Author: noamst-monday <[email protected]>
AuthorDate: Thu Jul 30 19:57:09 2026 +0300
Add `--min-completed-minutes` to `cleanup-pods` to prevent KPO race
condition (#70595)
* Add --min-completed-minutes to cleanup-pods to prevent KPO race condition
The ``airflow kubernetes cleanup-pods`` command currently deletes
Succeeded/Failed/Evicted pods immediately, with no minimum-age guard
for terminal states. ``KubernetesPodOperator`` in synchronous mode
polls pod status every ~2 seconds via ``await_pod_completion``. If
the cleanup job fires in the window between the pod reaching
``Succeeded`` and KPO's next poll, KPO receives a 404 and fails the
task -- even though the pod completed successfully (exit code 0).
A ``--min-pending-minutes`` guard already exists for Pending pods
(default 30 m, floor 5 m). No equivalent exists for terminal states.
This commit adds ``--min-completed-minutes`` (default ``0``, which
preserves the existing behaviour). When set to any positive value,
Succeeded/Failed/Evicted pods are skipped unless their completion time
is older than the threshold. Completion time is derived from the
latest ``containerStatuses[*].state.terminated.finishedAt`` timestamp
(falls back to ``metadata.creationTimestamp`` for pods that were
evicted before any container started).
Root-cause investigation
------------------------
This was confirmed via Kubernetes API server audit logs on a production
EKS cluster. Timeline for an affected KPO task:
13:15:12Z KPO polls pod → phase Running
13:15:14Z Container exits with code 0 (pod transitions to Succeeded)
13:15:17Z cleanup-pods CronJob deletes the pod (3 s after completion)
13:15:18Z KPO polls pod → 404 Not Found → task marked FAILED
The pod had succeeded; the task failure was a false positive caused
entirely by the race. Multiple production DAGs exhibited the same
pattern with the cleanup CronJob set to run every 5 minutes.
Reducing the CronJob frequency is a partial mitigation (lowers the
probability) but does not eliminate the race. Setting
``--min-completed-minutes=5`` gives KPO a 5-minute window to observe
the terminal phase -- 150x wider than the 2 s poll interval -- closing
the race completely in practice.
Changes
-------
* ``definition.py`` – add ``ARG_MIN_COMPLETED_MINUTES``; wire into
cleanup-pods args tuple
* ``kubernetes_command.py`` – add ``_get_pod_completion_time()`` helper;
gate terminal-pod deletion by age when
``min_completed_minutes > 0``
* ``test_kubernetes_command.py`` – 4 new unit tests covering:
- Succeeded pod too young → not deleted
- Succeeded pod old enough → deleted
- Default (0) preserves immediate-deletion behaviour
- Failed/Never pod too young → not deleted
* ``changelog.rst`` – entry under 10.21.0
CLI docs (``cli-ref.rst``) are auto-generated via ``.. argparse::``
and will pick up the new flag automatically.
* Update changelog with PR number #70595
* Fix ruff D213 docstring format in _get_pod_completion_time
* Use exact poll interval (2 s) in --min-completed-minutes help text
* Improve --min-completed-minutes help text clarity
* Revert changelog.rst — auto-generated by release manager
* Address jedcunningham review on cleanup-pods min-completed-minutes
- Set default to 1 minute (no reason to keep the race by default)
- Shorten --min-completed-minutes help text
- Fix _get_pod_completion_time: scan init_container_statuses too;
fall back to max(conditions.last_transition_time) instead of
creation_timestamp (which predates actual completion)
- Add TestGetPodCompletionTime with real k8s model objects covering
main-only, init-only, both, conditions fallback, and creation_timestamp
last-resort cases
- Fix test_cleanup_min_completed_zero_deletes_immediately to pass
--min-completed-minutes=0 explicitly now that default is 1
* Test cleanup-pods age guard for evicted and init-container pods
The guard must not fall through to creation_timestamp for pods whose
containers never reached a terminated state — that path reports an
inflated age and deletes immediately, the unsafe direction. Freezing
time keeps every case at a realistic completion offset instead of a
timestamp in the future.
* Cover a terminated container status with no finish time
_get_pod_completion_time guards three levels of the container state, but
the fallback cases only reached the first two, so a terminated status
carrying no finishedAt went unexercised. Modelling the non-terminated
case as a waiting container also matches what k8s reports for a pod
evicted before its containers started.
* Use a tuple for parametrize names in cleanup-pods tests
* Name the pending-pod age condition in cleanup-pods
---
.../providers/cncf/kubernetes/cli/definition.py | 12 +-
.../cncf/kubernetes/cli/kubernetes_command.py | 45 +++++-
.../cncf/kubernetes/cli/test_kubernetes_command.py | 180 +++++++++++++++++++++
3 files changed, 229 insertions(+), 8 deletions(-)
diff --git
a/providers/cncf/kubernetes/src/airflow/providers/cncf/kubernetes/cli/definition.py
b/providers/cncf/kubernetes/src/airflow/providers/cncf/kubernetes/cli/definition.py
index 90929ffaf5f..cbcbfac9e45 100644
---
a/providers/cncf/kubernetes/src/airflow/providers/cncf/kubernetes/cli/definition.py
+++
b/providers/cncf/kubernetes/src/airflow/providers/cncf/kubernetes/cli/definition.py
@@ -68,6 +68,16 @@ ARG_MIN_PENDING_MINUTES = Arg(
),
)
+ARG_MIN_COMPLETED_MINUTES = Arg(
+ ("--min-completed-minutes",),
+ default=1,
+ type=positive_int(allow_zero=True),
+ help=(
+ "Minimum age in minutes of a completed (Succeeded/Failed/Evicted) pod
before it is deleted. "
+ "Default is 1. Set to 0 to delete immediately."
+ ),
+)
+
ARG_TEAM = Arg(
("--team",),
default=None,
@@ -84,7 +94,7 @@ KUBERNETES_COMMANDS = (
"in evicted/failed/succeeded/pending states"
),
func=lazy_load_command("airflow.providers.cncf.kubernetes.cli.kubernetes_command.cleanup_pods"),
- args=(ARG_NAMESPACE, ARG_MIN_PENDING_MINUTES, ARG_VERBOSE),
+ args=(ARG_NAMESPACE, ARG_MIN_PENDING_MINUTES,
ARG_MIN_COMPLETED_MINUTES, ARG_VERBOSE),
),
ActionCommand(
name="generate-dag-yaml",
diff --git
a/providers/cncf/kubernetes/src/airflow/providers/cncf/kubernetes/cli/kubernetes_command.py
b/providers/cncf/kubernetes/src/airflow/providers/cncf/kubernetes/cli/kubernetes_command.py
index ea59ae7755b..2d2490134dd 100644
---
a/providers/cncf/kubernetes/src/airflow/providers/cncf/kubernetes/cli/kubernetes_command.py
+++
b/providers/cncf/kubernetes/src/airflow/providers/cncf/kubernetes/cli/kubernetes_command.py
@@ -119,6 +119,31 @@ def generate_pod_yaml(args):
print(f"YAML output can be found at {yaml_output_path}")
+def _get_pod_completion_time(pod):
+ """
+ Return the time the pod's last container finished.
+
+ Scans both ``container_statuses`` and ``init_container_statuses`` and
returns the
+ latest ``finished_at`` timestamp. Falls back to the latest condition
+ ``last_transition_time`` (which is updated at the terminal transition),
and finally
+ to ``creation_timestamp`` as a last resort.
+ """
+ statuses = [*(pod.status.container_statuses or []),
*(pod.status.init_container_statuses or [])]
+ times = [
+ s.state.terminated.finished_at
+ for s in statuses
+ if s.state and s.state.terminated and s.state.terminated.finished_at
+ ]
+ if times:
+ return max(times)
+ condition_times = [
+ c.last_transition_time for c in (pod.status.conditions or []) if
c.last_transition_time
+ ]
+ if condition_times:
+ return max(condition_times)
+ return pod.metadata.creation_timestamp
+
+
@cli_utils.action_cli(check_db=False)
@providers_configuration_loaded
def cleanup_pods(args):
@@ -130,6 +155,8 @@ def cleanup_pods(args):
if min_pending_minutes < 5:
min_pending_minutes = 5
+ min_completed_minutes = args.min_completed_minutes
+
# https://kubernetes.io/docs/concepts/workloads/pods/pod-lifecycle/
# All Containers in the Pod have terminated in success, and will not be
restarted.
pod_succeeded = "succeeded"
@@ -173,16 +200,20 @@ def cleanup_pods(args):
pod_restart_policy = pod.spec.restart_policy.lower()
current_time = datetime.now(pod.metadata.creation_timestamp.tzinfo)
- if (
+ is_terminal = (
pod_phase == pod_succeeded
or (pod_phase == pod_failed and pod_restart_policy ==
pod_restart_policy_never)
or (pod_reason == pod_reason_evicted)
- or (
- pod_phase == pod_pending
- and current_time - pod.metadata.creation_timestamp
- > timedelta(minutes=min_pending_minutes)
- )
- ):
+ )
+ is_terminal_old_enough = is_terminal and (
+ min_completed_minutes == 0
+ or current_time - _get_pod_completion_time(pod) >
timedelta(minutes=min_completed_minutes)
+ )
+ is_pending_too_long = (
+ pod_phase == pod_pending
+ and current_time - pod.metadata.creation_timestamp >
timedelta(minutes=min_pending_minutes)
+ )
+ if is_terminal_old_enough or is_pending_too_long:
print(
f'Deleting pod "{pod_name}" phase "{pod_phase}" and reason
"{pod_reason}", '
f'restart policy "{pod_restart_policy}"'
diff --git
a/providers/cncf/kubernetes/tests/unit/cncf/kubernetes/cli/test_kubernetes_command.py
b/providers/cncf/kubernetes/tests/unit/cncf/kubernetes/cli/test_kubernetes_command.py
index 6ae8b81c50f..29eadcc55a2 100644
---
a/providers/cncf/kubernetes/tests/unit/cncf/kubernetes/cli/test_kubernetes_command.py
+++
b/providers/cncf/kubernetes/tests/unit/cncf/kubernetes/cli/test_kubernetes_command.py
@@ -18,12 +18,15 @@ from __future__ import annotations
import importlib
import os
+from datetime import timedelta
from unittest import mock
from unittest.mock import MagicMock, call
import kubernetes
import pytest
+import time_machine
from dateutil.parser import parse
+from kubernetes.client import models as k8s
from airflow.cli import cli_parser
from airflow.executors import executor_loader
@@ -34,6 +37,47 @@ from tests_common.test_utils.version_compat import
AIRFLOW_V_3_0_PLUS
pytestmark = pytest.mark.db_test
+NOW = parse("2024-01-01T13:15:17Z")
+
+
+def make_container_status(state):
+ return k8s.V1ContainerStatus(
+ name="base", ready=False, restart_count=0, image="img", image_id="id",
state=state
+ )
+
+
+def make_terminated_status(finished_at):
+ return make_container_status(
+
k8s.V1ContainerState(terminated=k8s.V1ContainerStateTerminated(exit_code=0,
finished_at=finished_at))
+ )
+
+
+def make_terminal_pod(
+ name,
+ phase,
+ reason=None,
+ restart_policy="Never",
+ finished_at=None,
+ init_finished_at=None,
+ condition_time=None,
+):
+ conditions = (
+ [k8s.V1PodCondition(type="Ready", status="False",
last_transition_time=condition_time)]
+ if condition_time
+ else []
+ )
+ return k8s.V1Pod(
+ metadata=k8s.V1ObjectMeta(name=name, creation_timestamp=NOW -
timedelta(hours=1)),
+ spec=k8s.V1PodSpec(containers=[], restart_policy=restart_policy),
+ status=k8s.V1PodStatus(
+ phase=phase,
+ reason=reason,
+ container_statuses=[make_terminated_status(finished_at)] if
finished_at else [],
+ init_container_statuses=[make_terminated_status(init_finished_at)]
if init_finished_at else [],
+ conditions=conditions,
+ ),
+ )
+
class TestGenerateDagYamlCommand:
@classmethod
@@ -283,3 +327,139 @@ class TestCleanUpPodsCommand:
list_namespaced_pod.assert_has_calls(calls)
delete_pod.assert_called_with("dummy", "awesome-namespace")
load_incluster_config.assert_called_once()
+
+ @pytest.mark.parametrize(
+ ("pod_kwargs", "min_completed_minutes", "expect_deleted"),
+ [
+ pytest.param(
+ {"phase": "Succeeded", "finished_at": NOW -
timedelta(seconds=3)},
+ 1,
+ False,
+ id="succeeded-just-finished-kept",
+ ),
+ pytest.param(
+ {"phase": "Succeeded", "finished_at": NOW -
timedelta(minutes=5)},
+ 1,
+ True,
+ id="succeeded-old-enough-deleted",
+ ),
+ pytest.param(
+ {"phase": "Succeeded", "finished_at": NOW -
timedelta(seconds=3)},
+ 0,
+ True,
+ id="zero-disables-guard",
+ ),
+ pytest.param(
+ {"phase": "Failed", "finished_at": NOW - timedelta(seconds=3)},
+ 1,
+ False,
+ id="failed-just-finished-kept",
+ ),
+ pytest.param(
+ {"phase": "Failed", "init_finished_at": NOW -
timedelta(seconds=3)},
+ 1,
+ False,
+ id="init-container-failed-just-finished-kept",
+ ),
+ pytest.param(
+ {"phase": "Failed", "reason": "Evicted", "condition_time": NOW
- timedelta(seconds=3)},
+ 1,
+ False,
+ id="evicted-before-containers-started-kept",
+ ),
+ pytest.param(
+ {"phase": "Failed", "reason": "Evicted", "condition_time": NOW
- timedelta(minutes=5)},
+ 1,
+ True,
+ id="evicted-old-enough-deleted",
+ ),
+ ],
+ )
+ @time_machine.travel(NOW, tick=False)
+
@mock.patch("airflow.providers.cncf.kubernetes.cli.kubernetes_command._delete_pod")
+ @mock.patch("kubernetes.client.CoreV1Api.list_namespaced_pod")
+ @mock.patch("kubernetes.config.load_incluster_config")
+ def test_cleanup_pods_min_completed_minutes(
+ self,
+ load_incluster_config,
+ list_namespaced_pod,
+ delete_pod,
+ pod_kwargs,
+ min_completed_minutes,
+ expect_deleted,
+ ):
+ pods = MagicMock()
+ pods.metadata._continue = None
+ pods.items = [make_terminal_pod("run-o1sxc2on", **pod_kwargs)]
+ list_namespaced_pod.return_value = pods
+ kubernetes_command.cleanup_pods(
+ self.parser.parse_args(
+ [
+ "kubernetes",
+ "cleanup-pods",
+ "--namespace",
+ "awesome-namespace",
+ "--min-completed-minutes",
+ str(min_completed_minutes),
+ ]
+ )
+ )
+ if expect_deleted:
+ delete_pod.assert_called_once_with("run-o1sxc2on",
"awesome-namespace")
+ else:
+ delete_pod.assert_not_called()
+
+
+class TestGetPodCompletionTime:
+ T1 = parse("2024-01-01T10:00:00Z")
+ T2 = parse("2024-01-01T10:05:00Z")
+ CREATED_AT = parse("2024-01-01T09:00:00Z") # earlier than T1/T2
+
+ def _pod(self, container_statuses=None, init_container_statuses=None,
conditions=None):
+ return k8s.V1Pod(
+ metadata=k8s.V1ObjectMeta(name="run-o1sxc2on",
creation_timestamp=self.CREATED_AT),
+ status=k8s.V1PodStatus(
+ container_statuses=container_statuses,
+ init_container_statuses=init_container_statuses,
+ conditions=conditions,
+ ),
+ )
+
+ def test_single_main_container(self):
+ pod = self._pod(container_statuses=[make_terminated_status(self.T1)])
+ assert kubernetes_command._get_pod_completion_time(pod) == self.T1
+
+ def test_single_init_container_no_main(self):
+ pod = self._pod(container_statuses=[],
init_container_statuses=[make_terminated_status(self.T1)])
+ assert kubernetes_command._get_pod_completion_time(pod) == self.T1
+
+ def test_main_and_init_returns_max(self):
+ pod = self._pod(
+ container_statuses=[make_terminated_status(self.T1)],
+ init_container_statuses=[make_terminated_status(self.T2)],
+ )
+ assert kubernetes_command._get_pod_completion_time(pod) == self.T2
+
+ @pytest.mark.parametrize(
+ "container_status",
+ [
+ pytest.param(make_container_status(state=None), id="no-state"),
+ pytest.param(
+ make_container_status(
+
k8s.V1ContainerState(waiting=k8s.V1ContainerStateWaiting(reason="ContainerCreating"))
+ ),
+ id="never-terminated",
+ ),
+ pytest.param(make_terminated_status(finished_at=None),
id="terminated-without-finished-at"),
+ ],
+ )
+ def test_falls_back_to_conditions(self, container_status):
+ pod = self._pod(
+ container_statuses=[container_status],
+ conditions=[k8s.V1PodCondition(type="Ready", status="False",
last_transition_time=self.T1)],
+ )
+ assert kubernetes_command._get_pod_completion_time(pod) == self.T1
+
+ def
test_no_containers_no_conditions_falls_back_to_creation_timestamp(self):
+ pod = self._pod(container_statuses=[], init_container_statuses=[],
conditions=[])
+ assert kubernetes_command._get_pod_completion_time(pod) ==
self.CREATED_AT