Skip to content

Add failure causes and infrastructure retry budgets - #66405

Draft
1fanwang wants to merge 10 commits into
apache:mainfrom
1fanwang:1fanwang/aip97-failure-details
Draft

Add failure causes and infrastructure retry budgets#66405
1fanwang wants to merge 10 commits into
apache:mainfrom
1fanwang:1fanwang/aip97-failure-details

Conversation

@1fanwang

@1fanwang1fanwang commented May 5, 2026

Copy link
Copy Markdown
Contributor

Airflow can record that a task failed without knowing that the platform removed its worker. The failure then looks like task code and consumes the task's normal retries.

This adds a typed cause contract to workers, the API, and BaseExecutor. Confirmed infrastructure failures can use a separate infra_retries budget; the default is 0, so upgrades do not add attempts. AIP-103 stores the count across scheduler restarts and task clears without a new migration. Ambiguous signals stay unclassified and keep today's behavior.

AIP: https://cwiki.apache.org/confluence/x/0ITMFw

Executor proofs: 1fanwang#28 (ECS Spot), 1fanwang#15 (Celery reason-only). Stateless fallback: 1fanwang#16.

End-to-end evidence

An external kill of a retries=0 task, driven through the real executor and scheduler path:

Cause reported by the executorTask outcome
none, which is what every executor does todayFAILED
infra with reason EvictedUP_FOR_RETRY, infrastructure retry 1/1
application with reason OOMKilledFAILED
reason WorkerLost with no kindFAILED
Commands and raw output
$ uv run --project airflow-core pytest airflow-core/tests/integration/executors/test_failure_cause.py -q -s... failure_kind=infra failure_reason=Evicted... Using infrastructure retry 1/1 ... reason=Evicted, max_tries=1... Marking task as UP_FOR_RETRY. dag_id=failure_cause_infra... failure_kind=application failure_reason=OOMKilled... Marking task as FAILED. dag_id=failure_cause_application... failure_kind=None failure_reason=WorkerLost... Marking task as FAILED. dag_id=failure_cause_reason_only... failure_kind=None failure_reason=None... Marking task as FAILED. dag_id=failure_cause_unclassified4 passed
$ kind create cluster --name aip97 --config kind-aip97.yaml # 1 control plane, 2 workers
$ kubectl run drain-victim --image=busybox:1.36 \ --overrides='{"spec":{"nodeName":"aip97-worker","terminationGracePeriodSeconds":5}}' \ -- sh -c 'trap "exit 143" TERM; while true; do sleep 1; done'
$ kubectl wait --for=condition=Ready pod/drain-victim --timeout=120s
$ kubectl taint node aip97-worker node.kubernetes.io/unschedulable=aip97:NoExecute
$ kubectl get pod drain-victim -o json | jq -c '[.status.phase, (.status.reason // "-"), ([.status.conditions[]?|select(.type=="DisruptionTarget" and .status=="True")|.reason]|first)]'["Failed","-","DeletionByTaintManager"]
$ uv run --project providers/cncf/kubernetes python -c 'from airflow.providers.cncf.kubernetes.executors.kubernetes_executor_utils import classify_pod_failure as c; print(c(dict(container_reason="Error", disruption_reason="DeletionByTaintManager")))'(<TaskFailureKind.INFRA: 'infra'>, 'DeletionByTaintManager')

Was generative AI tooling used to co-author this PR?
  • Yes: GitHub Copilot CLI 1.0.80 (GPT-5.6 Sol)

Generated-by: GitHub Copilot CLI 1.0.80 (GPT-5.6 Sol) following the guidelines


@1fanwang
1fanwangforce-pushed the 1fanwang/aip97-failure-details branch from 20d6bd9 to 8c53810CompareMay 5, 2026 10:21
@1fanwang
1fanwangforce-pushed the 1fanwang/aip97-failure-details branch from 57f338e to fcc393aCompareJuly 16, 2026 05:33
@1fanwang1fanwang reopened this Jul 16, 2026
@1fanwang
1fanwangforce-pushed the 1fanwang/aip97-failure-details branch from fcc393a to bd8e7ecCompareJuly 24, 2026 19:28
@1fanwang1fanwang changed the title AIP-97 (draft): add FailureDetails primitive for infrastructure-side failure contextAIP-97 (draft): failure context propagation and transparent infra retryJul 24, 2026
@1fanwang
1fanwangforce-pushed the 1fanwang/aip97-failure-details branch from bd8e7ec to e87f0baCompareJuly 24, 2026 19:38
@1fanwang
1fanwangforce-pushed the 1fanwang/aip97-failure-details branch from e87f0ba to 7d568c9CompareJuly 24, 2026 22:39
@1fanwang1fanwang changed the title AIP-97 (draft): failure context propagation and transparent infra retryAIP-97 (draft): Disruption Readiness (Failure Context Propagation and Transparent Infra Retries)Jul 27, 2026
@1fanwang
1fanwangforce-pushed the 1fanwang/aip97-failure-details branch 2 times, most recently from ab413e1 to c822f45CompareJuly 27, 2026 19:25
@1fanwang
1fanwangforce-pushed the 1fanwang/aip97-failure-details branch 2 times, most recently from aa82a98 to 0de4133CompareJuly 28, 2026 01:13
@1fanwang1fanwang reopened this Jul 28, 2026
@1fanwang
1fanwangforce-pushed the 1fanwang/aip97-failure-details branch from 0de4133 to 4a545fdCompareJuly 28, 2026 07:45
@1fanwang1fanwang changed the title AIP-97 (draft): Disruption Readiness (Transparent Infra Failure Retries through Context Capture and Propagation)AIP-97: Disruption Readiness (Transparent Infra Failure Retries through Context Capture and Propagation)Jul 29, 2026
@1fanwang
1fanwangforce-pushed the 1fanwang/aip97-failure-details branch from 47a4827 to 8d4d715CompareJuly 30, 2026 04:19
@1fanwang1fanwang reopened this Jul 30, 2026
@potiuk
potiuk marked this pull request as draft August 13, 2026 12:55
@1fanwang
1fanwangforce-pushed the 1fanwang/aip97-failure-details branch from baeab77 to eb329f4CompareAugust 25, 2026 04:29
@1fanwang1fanwang changed the title AIP-97: Disruption Readiness (Transparent Infra Failure Retries through Context Capture and Propagation)Add failure causes and infrastructure retry budgetsAug 25, 2026
@1fanwang
1fanwangforce-pushed the 1fanwang/aip97-failure-details branch 2 times, most recently from 97fefd5 to 6abd295CompareAugust 25, 2026 15:22
…nfra retries (no DB)
Pillar 1: add a `failure_kind` argument (infra / application / timeout / manual)
and a transient `reason` token to the `on_task_instance_failed` listener hook.
The worker sets application or timeout for the failures it catches; the
scheduler sets infra for a task killed from outside. The Kubernetes executor
classifies a failed pod (Evicted / preempted is infra; an app crash or an
OOMKill against the container's own limit is application). pluggy dispatches by
name, so a listener that does not declare the arguments is unchanged.
Pillar 2: a failure classified as infra gives back the retry it used (reuses
`max_tries`, bounded by `[core] max_infra_refunds`), so infra churn does not
spend a task's `retries`. Opt-in via `[core] infra_failure_refund_retries`, off
by default. This holds even at `retries=0`: `is_eligible_to_retry` now gates on
`max_tries` (the retry ceiling the refund bumps), matching the execution API's
`_is_eligible_to_retry`, rather than short-circuiting on the user's `retries`.
The two retry-decision paths no longer disagree, and no per-task counter or
column is needed.
No schema change: the reason is handed to the listener at failure time rather
than persisted, so this backports cleanly to older release branches with no
upgrade or rollback step. How the reason token best reaches the listener (this
transient `reason` argument, or folding it into the existing `error` argument)
is AIP-97 open question 3.
Signed-off-by: 1fanwang <1fannnw@gmail.com>
finalize() omitted reason= on both on_task_instance_failed calls. pluggy raises
HookCallError when a hookspec argument is missing, and the call is wrapped in a
bare except, so a listener using the signature the hookspec documents silently
never fired on the worker path.
Also trims narrating comments and docstrings down to the reasons the code can't
carry, and corrects handle_failure's docstring, which said reason reaches the
listener as error.
Signed-off-by: 1fanwang <1fannnw@gmail.com>
Four of the reasons could never match. TerminationByKubelet, DeletionByTaintManager
and DisruptionTarget are pod *condition* reasons, and Shutdown is a node event
reason; the classifier reads only pod.status.reason and a container's
terminated/waiting reason, so none of them was reachable. The tests asserted the
same wrong assumption, so they passed.
Replace them with Terminated, which graceful node shutdown writes to
pod.status.reason when it kills an already-running pod. That is the node-drain
case this feature exists for, and it was missing. The kubelet is its sole writer,
so it cannot collide with an application exit, and it never appears as a container
reason.
Pin every remaining reason to its Go definition, and record that a taint-manager
eviction and a scheduler preemption stay unclassified until the classifier reads
pod.status.conditions.
Also type the signatures this change added and keyword the watcher call it
modified.
Signed-off-by: 1fanwang <1fannnw@gmail.com>
The stub classes and helpers this PR adds were half-typed: one _TI stub was
annotated while its sibling was not, and _eligible/_run left one parameter
bare. Types the remaining ones to their real domain types (_run's stashed is
the executor's failure-info tuple, not a flag).
Signed-off-by: 1fanwang <1fannnw@gmail.com>
The comment editorialised about a change that now has its own PR. Keep only
what stops the footgun it warned about: condition reasons are not matched
against pod.status.reason, so they do not belong in this set.
Signed-off-by: 1fanwang <1fannnw@gmail.com>
A node drain and a scheduler preemption are infrastructure taking the pod
away, but Airflow reported both to the listener as an application failure and
refunded nothing. Verified live on a k8s v1.35.0 cluster: both reach
phase=Failed with pod.status.reason empty and the container reading only
Error/exit 143, so neither the pod reason nor the container reason can see
them. The signal lives in pod.status.conditions[type=DisruptionTarget], which
the control plane sets before the delete and which survives onto the terminal
object.
Read that condition in collect_pod_failure_details and check it first in
classify_pod_failure. Gated on status "True", matching Kubernetes' own
podFailurePolicy matcher, since the writers update the condition in place.
Signed-off-by: 1fanwang <1fannnw@gmail.com>
Tasks should not lose retries meant for their own failures when a backend can prove that infrastructure stopped the worker. A separate durable budget also keeps the cap stable across scheduler restarts and task clears.
Signed-off-by: 1fanwang <1fannnw@gmail.com>
The integration path should prove that one executor event drives retry accounting, listeners, callback context, and persisted state consistently for classified and ambiguous failures.
Signed-off-by: 1fanwang <1fannnw@gmail.com>
The new cause fields reached runtime consumers, but the supervisor snapshot and response expectations still described the old payloads. The hybrid executor forwarding also targeted a runtime removed before this contract exists.
Signed-off-by: 1fanwang <1fannnw@gmail.com>
PythonVirtualenvOperator serializes only the context keys it declares, so
`failure_kind` and `failure_reason` were reaching a normal task but being
dropped inside a virtual environment. The guard test that compares the real
context against the declared set caught the gap on four operators.
They are gated on Airflow 3.4 because that is the release that introduces
them, and the standard provider still runs against 3.0 through 3.3, where the
context has no such keys and an unconditional entry would break the same test
in the other direction. This mirrors how `partition_key` is gated on 3.3.
The TypeScript supervisor schema is generated from the same wire model, so it
is regenerated here to match.
Signed-off-by: 1fanwang <1fannnw@gmail.com>
@1fanwang
1fanwangforce-pushed the 1fanwang/aip97-failure-details branch from e31e2b2 to 6f11163CompareAugust 29, 2026 14:28
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant

@1fanwang