Skip to content

AIP-97: Disruption Readiness (Transparent Infra Failure Retries through Context Capture and Propagation) - #66405

Open
1fanwang wants to merge 7 commits into
apache:mainfrom
1fanwang:1fanwang/aip97-failure-details
Open

AIP-97: Disruption Readiness (Transparent Infra Failure Retries through Context Capture and Propagation)#66405
1fanwang wants to merge 7 commits into
apache:mainfrom
1fanwang:1fanwang/aip97-failure-details

Conversation

@1fanwang

@1fanwang 1fanwang commented May 5, 2026

Copy link
Copy Markdown
Contributor

The implementation behind AIP-97: Disruption Readiness.

Why

When a task is killed from outside (eviction, OOM, force-delete, lost heartbeat), the worker dies without raising. An eviction and a real ValueError therefore look the same to on_task_instance_failed, to lineage, and to the retry counter. Airflow already requeues a pre-start pod without spending a retry; this extends that behavior to running tasks.

What

Pillar 1 adds an optional failure_kind enum (infra, application, timeout, manual) and a short reason token to the hook. Pluggy matches by name, so listeners that do not declare the new arguments are unchanged. The Kubernetes executor's classify_pod_failure() reads pod details it already collects, with its reason set pinned to the kubelet's Go definitions. Node drains and preemptions leave that reason empty, so it also reads pod.status.conditions[type=DisruptionTarget].

Pillar 2 is opt-in and off by default: on an infra failure, refund the attempt instead of charging the user's retries, reusing max_tries and capping at max_infra_refunds.

Retry eligibility

The refund lifts max_tries, so is_eligible_to_retry now reads max_tries rather than the user's retries. That matches the Execution API's _is_eligible_to_retry, which already dropped retries, so the two retry paths stop disagreeing and a retries=0 task gets the refund. Eligibility differs only once a refund lifts max_tries past retries.

Alternatives set aside: an infra_retry_count column would bring back the migration this avoids; a handle_failure bypass leaves the eligibility paths divergent; checkpoint-resume needs AIP-103 state this design does not keep.

No DB change

The reason reaches the listener as a transient argument, not a column, so there is no migration and it backports cleanly. Whether it rides reason or folds into error is open (#23 vs #22).

Related POCs

failure_kind also reaches callbacks, metrics (live OTel e2e), and scheduler logs. Other executor paths are POC'd for Celery and heartbeat, with config and listener docs.

Testing

Unit: AIP-97 tests pass across core, Kubernetes, the scheduler, and the task SDK; existing scheduler retry-eligibility tests are unchanged; mypy is clean.

Live on real Postgres 16, through the real (not mocked) handle_failure(failure_kind=...) path on 3ce5dbc184; core and task SDK runtime are unchanged since. A registered listener confirms it receives kind and reason. airflow db migrate stamps to the existing head:

retries=0 scenario state max_tries eligible
infra, refund on up_for_retry 0 to 1 yes
infra, refund off failed 0 no
application, refund on failed 0 no
Commands and raw logs
$ docker run -d --name pg -e POSTGRES_USER=airflow -e POSTGRES_PASSWORD=airflow     -e POSTGRES_DB=airflow -p 55432:5432 postgres:16
$ export AIRFLOW__DATABASE__SQL_ALCHEMY_CONN=    "******localhost:55432/airflow"

$ airflow db migrate
  Running stamp_revision  -> 02ca36b0235b
  Database migration done!                       # existing head, no new column

$ pytest airflow-core/tests/unit/models/test_e2e_retries0_live.py -q -s
[e2e] backing store: localhost:55432/airflow
  start (retries=0)      state=None          max_tries=0 eligible=False
  after infra kill       state=up_for_retry  max_tries=1 eligible=True
  flag off, infra kill   state=failed        max_tries=0 eligible=False
  app failure            state=failed        max_tries=0 eligible=False

# the same run, real state-machine transitions (pytest -s):
  Marking task as UP_FOR_RETRY. dag_id=e2e_infra_on   task_id=op
  Marking task as FAILED.       dag_id=e2e_infra_off  task_id=op
  Marking task as FAILED.       dag_id=e2e_app        task_id=op
1 passed

Kubernetes, live on a v1.35.0 cluster. A node drain and a preemption are indistinguishable from a crash except for the condition:

disruption pod.status.reason container DisruptionTarget kind
node drain (taint) empty Error, exit 143 True/DeletionByTaintManager infra
preemption empty Error, exit 143 True/PreemptionByScheduler infra
Commands and raw logs
$ kind create cluster --name aip97 --image kindest/node:v1.35.0   # 1 control-plane, 2 workers
$ kubectl taint node aip97-worker node.kubernetes.io/unschedulable=:NoExecute   # what a drain does

# every watch event on the victim pod, node drain:
  12.4s  MODIFIED  phase=Running  deletionTS=-    status.reason=None  DisruptionTarget=True/DeletionByTaintManager  container=-
  17.8s  MODIFIED  phase=Failed   deletionTS=SET  status.reason=None  DisruptionTarget=True/DeletionByTaintManager  container=term:Error:exit=143
  18.1s  DELETED   phase=Failed   deletionTS=SET  status.reason=None  DisruptionTarget=True/DeletionByTaintManager  container=term:Error:exit=143

# scheduler preemption, same shape:
  12.5s  MODIFIED  phase=Running  deletionTS=-    status.reason=None  DisruptionTarget=True/PreemptionByScheduler   container=-
  17.7s  MODIFIED  phase=Failed   deletionTS=SET  status.reason=None  DisruptionTarget=True/PreemptionByScheduler   container=term:Error:exit=143
  18.2s  DELETED   phase=Failed   deletionTS=SET  status.reason=None  DisruptionTarget=True/PreemptionByScheduler   container=term:Error:exit=143

# the pod object recorded above, replayed through the classifier:
$ pytest providers/cncf/kubernetes/tests/unit/cncf/kubernetes/executors/test_classify_pod_failure.py -q
24 passed
# on the pre-fix source the 8 disruption cases fail; both above classify as application.

Both providers still import on Airflow 3.3.0; classify_pod_failure is gated to 3.4+.

@1fanwang
1fanwang force-pushed the 1fanwang/aip97-failure-details branch from 20d6bd9 to 8c53810 Compare May 5, 2026 10:21
@1fanwang
1fanwang force-pushed the 1fanwang/aip97-failure-details branch from 57f338e to fcc393a Compare July 16, 2026 05:33
@1fanwang 1fanwang closed this Jul 16, 2026
@1fanwang 1fanwang reopened this Jul 16, 2026
@1fanwang
1fanwang force-pushed the 1fanwang/aip97-failure-details branch from fcc393a to bd8e7ec Compare July 24, 2026 19:28
@1fanwang 1fanwang changed the title AIP-97 (draft): add FailureDetails primitive for infrastructure-side failure context AIP-97 (draft): failure context propagation and transparent infra retry Jul 24, 2026
@1fanwang
1fanwang force-pushed the 1fanwang/aip97-failure-details branch from bd8e7ec to e87f0ba Compare July 24, 2026 19:38
@1fanwang
1fanwang force-pushed the 1fanwang/aip97-failure-details branch from e87f0ba to 7d568c9 Compare July 24, 2026 22:39
@1fanwang 1fanwang changed the title AIP-97 (draft): failure context propagation and transparent infra retry AIP-97 (draft): Disruption Readiness (Failure Context Propagation and Transparent Infra Retries) Jul 27, 2026
@1fanwang
1fanwang force-pushed the 1fanwang/aip97-failure-details branch 2 times, most recently from ab413e1 to c822f45 Compare July 27, 2026 19:25
@1fanwang
1fanwang force-pushed the 1fanwang/aip97-failure-details branch 2 times, most recently from aa82a98 to 0de4133 Compare July 28, 2026 01:13
@1fanwang 1fanwang closed this Jul 28, 2026
@1fanwang 1fanwang reopened this Jul 28, 2026
@1fanwang
1fanwang force-pushed the 1fanwang/aip97-failure-details branch from 0de4133 to 4a545fd Compare July 28, 2026 07:45
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>
@1fanwang 1fanwang 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 added 2 commits July 29, 2026 17:35
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>
@1fanwang
1fanwang force-pushed the 1fanwang/aip97-failure-details branch from 47a4827 to 8d4d715 Compare July 30, 2026 04:19
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>
@1fanwang 1fanwang closed this Jul 30, 2026
@1fanwang 1fanwang reopened this Jul 30, 2026
Sign up for free to 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