seanmuth opened a new issue, #69052:
URL: https://github.com/apache/airflow/issues/69052
## Summary
When a worker pod fails **before the task process begins** — due to a node
drain, an autoscaler scale-down, a node boot race, a transient image registry
issue, or any other cause — the KubernetesExecutor today reports the task as
`FAILED` and the scheduler processes it identically to a task that ran and
raised an exception. This burns a user-configured retry slot, or surfaces a
permanent `FAILED` state to the user for a task that never actually executed.
These are two fundamentally different failure classes:
| Class | Description | Correct disposition |
|---|---|---|
| **Execution failure** | Task process ran, raised an exception | Consume
retry, surface to user |
| **Pre-execution failure** | Pod failed before task process started |
Requeue transparently, no retry consumed |
The executor already has all the signal it needs to distinguish them. It
does not act on it.
---
## The signal
Two conditions, both required:
1. **The executor received `state=FAILED` from the Kubernetes watcher** —
the pod actually terminated. This distinguishes a genuine pod failure from
transient watcher events or state=None lookups.
2. **The TI is still in `queued` state in the DB** — the task process never
ran. The Airflow task process writes `running` to the database early in its
startup sequence. A TI that never left `queued` definitively never executed,
regardless of what killed the pod. This is true across all pre-execution
failure causes:
| Container reason | Cause | Transient? |
|---|---|---|
| `ContainerStatusUnknown` | Node killed/drained mid-pod-start; container
runtime unreachable | Yes |
| `ImagePullBackOff` / `ErrImagePull` | Transient registry outage, rate
limit, or missing image | Often |
| `CreateContainerError` | Volume mount failure, secret unavailable, IOPS
saturation | Often |
| `StartError` | OCI runtime error at container init (e.g. config map
unavailable) | Often |
| `OOMKilled` (init container) | Init container OOM before task process
started | Varies |
Whether the underlying cause is transient or not, a single executor-level
requeue is appropriate: if the cause was transient, the retry succeeds
silently; if it was persistent (e.g. genuinely missing image), the retry fails
again and the task surfaces to the user — which is where it would have ended up
anyway, but now correctly attributed after a second attempt rather than on the
first occurrence.
### Known false positive: worker process starts but crashes before DB write
There is a narrow window where `state=FAILED AND ti_state=QUEUED` fires but
the cause is not an infrastructure event: the container started executing
`airflow tasks run`, the Python process initialised, but something caused it to
crash **before** the step that writes `running` to the task instance record. In
this case the pod exits with `exit_code: 1` and `container_reason: Error` — a
voluntary process exit recorded cleanly by the container runtime, not an
OS-level kill.
Realistic causes: transient metadata DB connection failure, fernet key
error, plugin initialisation failure, or a DAG-file import error that only
surfaces at runtime.
Once the container has started executing code it is more likely that an
Airflow-specific error is responsible than a transient infrastructure event,
and it is cleaner to have that failure consume a full task retry so the failure
is visible and attributed correctly — rather than disappearing into a silent
requeue.
**Proposed handling:** a configurable excluded-reasons list, defaulting to
`Error`. Pods whose `container_reason` appears in the exclusion list fall
through to the normal FAILED path and consume a task retry as usual. Operators
who know their environment produces transient startup crashes (e.g.
intermittent DB connectivity at pod boot) can remove `Error` from the list.
Operators who want to exclude additional reasons can extend it.
### What about OOMKill of a running task?
`exit_code: 137` (SIGKILL) also occurs when the kernel OOM killer fires. In
Kubernetes the container is its own cgroup, so the OOM killer fires against the
container's cgroup and kills the container process — the container runtime
records this cleanly as `container_reason: OOMKilled`. This is the standard,
expected behavior for the vast majority of deployments.
The TI state check handles this correctly in all cases: a task that was
actually executing would have already transitioned the TI to `running`, so
`ti_state == QUEUED` cannot be true for a running task that gets OOM-killed. No
special casing is needed.
---
## Current behavior in `_change_state()`
`_change_state()` in `kubernetes_executor.py` receives the populated
`failure_details` dict, logs all fields, then unconditionally writes
`(TaskInstanceState.FAILED, termination_reason)` to `event_buffer`. The
scheduler receives `executor_state=failed` against a TI that is still in
`queued` state (logged as a state mismatch), decrements the retry counter, and
marks the TI `UP_FOR_RETRY` or `FAILED`.
There is no path today that says "the executor knows this pod failed before
the task ran — requeue it without touching the retry counter."
### Existing precedent
`task_publish_max_retries` already implements executor-level retry for pod
**creation** failures (quota exceeded, 409 Conflict on the k8s API). It
requeues via `task_queue` and increments an executor-local counter,
independently of the task's `retries` setting. The same pattern applies here
for pod **start** failures.
---
## Proposed change
### Detection
In `_change_state()`, query TI state from the session (the method is already
`@provide_session` and already does this lookup in the `state is None` branch)
and add:
```python
def _is_pre_execution_failure(
state: TaskInstanceState,
ti_state: TaskInstanceState | None,
failure_details: FailureDetails | None,
excluded_container_reasons: frozenset[str],
) -> bool:
"""
Returns True if the pod terminated with FAILED but the task process never
started — the executor received a FAILED signal from a terminated pod
while
the TI is still in 'queued' state in the DB.
Both conditions are required:
- state == FAILED: the pod actually terminated; guards against matching
on
transient watcher events where state is None or non-terminal.
- ti_state == QUEUED: the task process never ran and never transitioned
to
'running'. Any pod failure in this state is eligible for an
executor-level
requeue regardless of the specific container failure reason (node
drain,
autoscaler, transient image pull error, etc.).
Pods whose container_reason appears in excluded_container_reasons fall
through to the normal FAILED path and consume a task retry as usual.
The default exclusion of 'Error' covers the case where the container
started executing but the worker process crashed before writing 'running'
to the DB — most likely an Airflow-specific startup error rather than a
transient infrastructure event.
"""
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
```
`excluded_container_reasons` is read from config at executor startup as a
`frozenset[str]` and passed through to the helper.
### Disposition
When `_is_pre_execution_failure()` is True and the executor-local count for
that TI key is below `pod_launch_failure_retries`:
1. **Do not** write to `event_buffer` as FAILED.
2. Requeue the TI key onto `task_queue` (same mechanism as
`task_publish_max_retries`).
3. Increment an executor-local `pod_launch_failure_retries_count:
Counter[TaskInstanceKey]`.
4. Log at WARNING level with the full failure details and retry count so
operators can observe what is happening without being paged.
If the cap is exceeded, fall through to the existing FAILED path.
### New config keys
```ini
[kubernetes_executor]
# Number of times the executor will transparently requeue a task whose pod
# failed before the task process started (node drain, autoscaler event,
# transient image pull failure, etc.). Does not consume task-level retries.
# Set to 0 to disable. Default: 1.
pod_launch_failure_retries = 1
# Comma-separated list of container reasons that are excluded from the
# transparent requeue path even when the TI is still in queued state.
# Pods that fail with an excluded reason consume a normal task retry instead.
# Default: Error — the container ran but the process exited voluntarily
# (most likely an Airflow-specific startup error such as a DB connection
# failure or plugin init error). Remove Error to also requeue these cases,
# or add further reasons to tighten the exclusion.
pod_launch_failure_excluded_container_reasons = Error
```
Default of `1` retry reflects the expectation that most pre-execution
failures are transient: one silent retry handles the common case (node
replaced, registry recovers) while keeping the blast radius small. Operators
running in environments with aggressive autoscaling or frequent rolling
upgrades can increase this.
The default exclusion of `Error` is conservative: once the container has
started executing, a voluntary process exit is more likely an Airflow-specific
startup error than a transient infrastructure event, and should be visible to
the user as a normal task failure. Operators who know their environment
produces transient startup crashes (e.g. intermittent DB connectivity at pod
boot) can clear this to an empty value.
---
## What this does NOT change
- Tasks that fail **during** execution — the TI has already transitioned to
`running` — are completely unaffected. Normal retry logic applies.
- User-configured `retries` on the task are not consumed by
`pod_launch_failure_retries`.
- The existing `task_publish_max_retries` behavior for pod creation-side
failures (quota, conflict) is unaffected.
- Deferrable operator tasks that fail in the **post-deferral resume pod**
(`execute_complete`) are **explicitly in scope**. When the triggerer fires and
the resume pod is created, the TI transitions back to `queued`. If the pod is
killed before `execute_complete()` starts, `ti_state == QUEUED` holds and the
detection fires. This case is arguably the highest-value target for a
transparent requeue: the external work (a BigQuery job, a Snowflake query, a
dbt run) has already completed successfully, and the only remaining step is
running `execute_complete()` to record the result. Losing the resume pod
without retrying means discarding confirmed successful external work and
potentially re-triggering expensive or non-idempotent compute on the next full
task retry.
---
## Relationship to AIP-97
[PR #66405](https://github.com/apache/airflow/pull/66405) (AIP-97, DRAFT)
proposes a `FailureDetails` primitive on `on_task_instance_failed` so listeners
can route infrastructure failures separately from code bugs. That work and this
issue are complementary but independent:
- AIP-97 solves **observability**: making failure context visible to
listeners and alerting logic after the fact.
- This issue solves **retry behavior**: preventing pre-execution pod
failures from consuming task retry slots and surfacing false-failure alerts in
the first place.
---
## Affected components
-
`providers/cncf/kubernetes/src/airflow/providers/cncf/kubernetes/executors/kubernetes_executor.py`
— `_change_state()`, new `_is_pre_execution_failure()` helper (takes `state`,
`ti_state`, `failure_details`, `excluded_container_reasons`), new
`pod_launch_failure_retries_count` counter, `pod_launch_failure_retries` and
`pod_launch_failure_excluded_container_reasons` config reads
-
`providers/cncf/kubernetes/src/airflow/providers/cncf/kubernetes/executors/kubernetes_executor_utils.py`
— no changes required
- Config documentation for the two new keys
---
## Are you willing to submit a PR?
Yes — work on the PR is starting immediately.
---
_Issue drafted by Claude Sonnet 4.6, reviewed and edited by Sean Muth
(Astronomer, Staff Airflow Reliability Engineer)._
--
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.
To unsubscribe, e-mail: [email protected]
For queries about this service, please contact Infrastructure at:
[email protected]