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#70595noamst-monday wants to merge 7 commits into
--min-completed-minutes to cleanup-pods to prevent KPO race condition#70595Conversation
…tion
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.
|
Congratulations on your first Pull Request and welcome to the Apache Airflow community! If you have any issues or are unsure about any anything please check our Contributors' Guide
|
|
|
||
| ARG_MIN_COMPLETED_MINUTES = Arg( | ||
| ("--min-completed-minutes",), | ||
| default=0, |
There was a problem hiding this comment.
Do we want a different default here? Maybe allow >3 seconds grace by default to be safe from KPO polling interval?
| for status in pod.status.container_statuses or []: | ||
| if status.state and status.state.terminated and status.state.terminated.finished_at: | ||
| times.append(status.state.terminated.finished_at) | ||
| return max(times) if times else pod.metadata.creation_timestamp |
There was a problem hiding this comment.
Not sure about this one, open for suggestions
| type=positive_int(allow_zero=True), | ||
| help=( | ||
| "Minimum age in minutes of a completed (Succeeded/Failed/Evicted) pod before it is deleted. " | ||
| "Defaults to 0 (delete immediately, preserving current behaviour). " |
There was a problem hiding this comment.
Lets set a default age to avoid this race. No reason to maintain a race condition :)
| "Set this to a positive value to prevent a race condition where the cleanup job removes a " | ||
| "just-completed pod before KubernetesPodOperator has polled its terminal phase, " | ||
| "causing a spurious task failure despite the pod having succeeded." |
There was a problem hiding this comment.
We can probably shorten or remove this completely.
| the time the *last* container finished, not by when the pod was created. | ||
| """ | ||
| times = [] | ||
| for status in pod.status.container_statuses or []: |
There was a problem hiding this comment.
Init container statuses live in a separate field, pod.status.init_container_statuses, so they're never seen.
That matters because of the fallback direction. When an init container fails, the pod goes Failed/restartPolicy: Never, but the base container never starts, so it has no terminated state and times comes back empty. Then we return creation_timestamp, which is always earlier than real completion, so the age comes out inflated and the pod is deleted immediately. Same for pods evicted before containers started. In other words the guard silently no-ops in some of the exact terminal states the flag advertises, and it fails in the unsafe direction (delete too early) rather than the safe one.
Two changes:
- Scan init container statuses too:
statuses = [*(pod.status.container_statuses or []), *(pod.status.init_container_statuses or [])]
- Pick a fallback that isn't strictly older than completion.
max(c.last_transition_time for c in pod.status.conditions)is at-or-after the terminal transition (the Ready condition flips on eviction) and is always present for a scheduled pod.
Also, no test currently covers either behavior the helper exists for — every case builds exactly one container status, so max() over multiple is unexercised, and because _make_pod uses a bare MagicMock(), status.state and status.state.terminated are auto-truthy and the None guards can't be exercised as false. A small parametrized unit test on _get_pod_completion_time with real k8s.V1ContainerStatus objects would cover both plus the fallback. Or, cover it with more comprehensive coverage of the cli command directly would work too.
There was a problem hiding this comment.
Thanks! Appreciate your review, I'll take a look and update.
Problem
KubernetesPodOperatorin synchronous (deferrable=False) mode callsPodManager.await_pod_completionto confirm the pod reached a terminal phase. This method pollsread_podevery 2 seconds (pod_manager.py:839, hardcodedtime.sleep(2)).When a pod transitions to
Succeeded, there is up to a 2-second window beforePodManager.await_pod_completionobserves the terminal phase. Theairflow kubernetes cleanup-podscommand currently deletes Succeeded/Failed/Evicted pods immediately — there is no minimum-age guard for terminal states.If the cleanup job fires during that window,
PodManager.await_pod_completion's nextread_podcall returns 404 and the task is marked FAILED, even though the pod completed successfully (exit code 0).A
--min-pending-minutesguard already exists for Pending pods (default 30 m, minimum 5 m). No equivalent exists for terminal states.Real-world reproduction
This was confirmed via Kubernetes API server audit logs on a production EKS cluster running
apache-airflow-providers-cncf-kubernetes==10.19.0on Airflow 3.2.2. The cleanup CronJob was set to run every 5 minutes.Timeline of a representative failure:
The pod had succeeded; the task failure was a false positive. Multiple DAGs exhibited the same pattern on the same day.
Airflow task log excerpt:
Audit log at deletion time confirmed
phase: Succeeded,containerStatuses[0].state.terminated.exitCode: 0,finishedAt: 13:15:14Z.Reducing the CronJob frequency is a partial mitigation (lowers probability) but does not eliminate the race — the window is a function of the poll interval, not the CronJob frequency.
Related
PR #69269 fixed a similar 404 race in
is_istio_enabledfor the deferrabletrigger_reentrypath (merged into 10.20.0). This PR addresses the complementary gap: preventing the race at source by not deleting recently-completed pods.Solution
Add
--min-completed-minutes(default0, preserving existing behaviour) to thecleanup-podscommand. When set to any positive value, Succeeded/Failed/Evicted pods are skipped unless their completion time exceeds the threshold.Completion time is derived from
max(containerStatuses[*].state.terminated.finishedAt)— the latest container finish time across all containers — which is the most accurate signal for "when the pod entered a terminal state". Falls back tometadata.creationTimestampfor pods evicted before any container started.Setting
--min-completed-minutes=5givesPodManager.await_pod_completiona 5-minute observation window — 150× wider than the 2 s poll interval — closing the race completely in practice.Changes
cli/definition.pyARG_MIN_COMPLETED_MINUTES(default0,allow_zero=True); wire into cleanup-pods argscli/kubernetes_command.py_get_pod_completion_time()helper; gate terminal-pod deletion by agetests/.../test_kubernetes_command.pydocs/changelog.rstCLI reference docs (
cli-ref.rst) use.. argparse::and pick up the new flag automatically.Testing
All existing tests continue to pass (default=0 preserves current behaviour). Four new tests cover the new flag.
Usage
In the cleanup CronJob, add the flag:
Or in the Airflow Helm chart values:
Was generative AI tooling used to co-author this PR?
Generated-by: Claude Sonnet 4.6 following the guidelines