1fanwang opened a new pull request, #66405:
URL: https://github.com/apache/airflow/pull/66405

   Filing this AIP draft from the LinkedIn DI side. Today when a task fails for 
infrastructure reasons — pod evicted, OOM-killed, host went down, image pull 
error — the user sees a generic stack trace from the worker side, not the 
actual reason. We want a `FailureDetails` primitive so infra-side context 
(executor type, eviction reason, OOM killer details, etc.) flows into the TI 
failure record.
   
   ## What this looks like in production today
   
   A meaningful share of task failures at LinkedIn DI's Airflow deployment are 
infrastructure failures, not application bugs — Kubernetes OOMKills, pod 
evictions during node drains, image-pull retries that exceed backoff, hardware 
preemption on spot capacity. Today they all surface to listeners as the 
worker's last caught exception, which is either generic (`ValueError`, 
`RuntimeError`, `BrokenPipeError`) or — when the worker dies before catching 
anything — a synthetic state from the scheduler's heartbeat-timeout path. The 
listener can't distinguish a code bug from a pod evicted because the node went 
down; both look identical to alerting logic.
   
   The downstream effect: on-call gets paged for what looks like a `ValueError` 
from a job, and only after manually correlating scheduler logs against kubelet 
events / pod terminations do they discover it was a node drain or an OOMKill. 
The information existed at the executor — Kubernetes set the pod's 
`terminationReason: OOMKilled` or `reason: Evicted` at the moment of failure — 
but it never reached the listener because there's no carrier on the hookspec. 
`FailureDetails` is that carrier: a structured payload the executor populates 
on infra-side failures so listeners route "this is an infra failure, retry it" 
separately from "this is a bug, page the author".
   
   AIP-97 (Infrastructure-Aware Task Execution) is currently in DRAFT on the
   cwiki. Opening this as the smallest concrete artifact so the listener-side
   API shape can be discussed against real code — marking the PR draft for
   that reason.
   
   The gap: `on_task_instance_failed` only sees the worker-side `error`
   exception. Failure causes that originate **outside** the worker process
   (Kubernetes `OOMKilled`/`PodEvicted`/`ImagePullBackOff`, Celery
   `WorkerLost`/`SoftTimeLimit`, LocalExecutor oom-killer `SIGKILL`, and so
   on) are visible only to the executor and never reach the listener. The
   shape is the same across executors — a kind tag, a categorical reason,
   structured metadata — and `FailureDetails` formalises it:
   
   ```python
   from airflow.listeners import hookimpl
   from airflow.listeners.types import FailureDetails
   
   class InfraTrackingListener:
       @hookimpl
       def on_task_instance_failed(
           self,
           previous_state,
           task_instance,
           error,
           failure_details: FailureDetails | None,  # NOTE: do not assign a 
default — see hookspec docstring
       ):
           if failure_details and failure_details.infra_reason == "OOMKilled":
               ...  # route to capacity-planning alert
   ```
   
   `FailureDetails` is a frozen `attrs` class with `executor_kind`,
   `infra_reason`, `infra_metadata`. The hookspec gains the optional
   `failure_details` keyword arg; pluggy dispatches by name, so existing
   hookimpls that don't declare it keep working.
   
   Per-executor wiring (Kubernetes, Celery, LocalExecutor, and any other
   executor that surfaces eviction/preemption) is intentionally deferred so
   each can iterate against a fixed contract. Persisting `FailureDetails` on
   the TaskInstance for UI rendering is also a separate question.
   
   A few questions worth settling on the discussion:
   
   - `infra_reason` as free-form string vs constrained enum — free-form here
     for permissiveness, open to switching.
   - Whether a sibling `FailureDetails` belongs on `on_task_instance_success`
     for success-with-warnings cases (out of scope here; the failure path is
     the demonstrably-broken one).
   - Naming: `FailureDetails` vs `InfraFailureDetails` vs
     `ExecutorFailureContext`.
   
   ## Testing
   
   - Parametrized ``TestFailureDetails.test_construct`` covers five
     realistic executor / reason / metadata shapes (k8s OOM, k8s
     evicted, celery worker-lost, local SIGKILL, unknown reason).
   - ``test_default_metadata_is_empty_dict`` and ``test_frozen`` lock
     the dataclass invariants.
   - ``TestOnTaskInstanceFailedAcceptsFailureDetails`` exercises the
     pluggy-dispatch contract: the new listener receives the payload,
     the legacy listener (no ``failure_details`` parameter) still gets
     called.
   
   <!-- Please keep an empty line above the dashes. -->
   ---
   **^ Add meaningful description above**
   Read the **[Pull Request 
Guidelines](https://github.com/apache/airflow/blob/main/contributing-docs/05_pull_requests.rst#pull-request-guidelines)**
 for more information.
   
   
   
   ## E2E validation
   
   ### Bug found and fixed during validation
   
   Initial implementation had `failure_details: FailureDetails | None = None` 
in the hookspec. End-to-end pluggy dispatch revealed a quirk: when a listener 
implementation declares the same parameter with a default 
(`failure_details=None`), pluggy treats the impl-side default as authoritative 
and silently overrides the value the caller passed. Removed the default from 
the hookspec and added an explicit warning to the docstring telling impl 
authors to declare `failure_details` without a default.
   
   ### After-fix runtime check
   
   ```
   class New:
       @hookimpl
       def on_task_instance_failed(self, previous_state, task_instance, error, 
failure_details):
           ...
   
   class Legacy:
       @hookimpl
       def on_task_instance_failed(self, previous_state, task_instance, error):
           ...
   
   caller: hook.on_task_instance_failed(..., 
failure_details=FailureDetails(executor_kind="kubernetes", 
infra_reason="OOMKilled", ...))
   
   results:
     ('legacy',)                                                  # legacy 
listener still fires
     ('new', FailureDetails(executor_kind='kubernetes',
                            infra_reason='OOMKilled',
                            infra_metadata={'exit_code': 137}))    # new 
listener gets the value
   ```
   
   The fix means the impl-side signature must be exactly `failure_details` (no 
default). Documented in the hookspec.
   
   
   ## Real e2e validation surfaced another bug — fixed in this PR
   
   Re-ran with `airflow standalone` against the worktree's editable install and 
dropped a listener that declares `failure_details` (no default, per the 
hookspec docstring). Triggered a failing DAG.
   
   **First run revealed a follow-on bug from removing the default:** every 
existing `on_task_instance_failed` call site in the codebase (`task_runner.py` 
for the worker, `taskinstance.py` for the API server retry path, 
`_emit_state_listener_hooks` for manual API state changes) didn't pass 
`failure_details`, and pluggy raised `HookCallError: hook call must provide 
argument 'failure_details'` once the hookspec required it. Every task failure 
on the upstream branch would have hit this.
   
   Fix: pass `failure_details=None` at every call site until each executor's 
wiring PR populates it. Commit `9a51cbbe60` updates the three call sites.
   
   After the fix, the listener receives the call cleanly:
   
   ```
   failed   task=boom_task   error_type=ValueError   failure_details=None
   ```
   
   `failure_details=None` is the expected value today — no executor populates 
this field yet, that's deferred to per-executor follow-up PRs (Kubernetes, 
Celery, LocalExecutor). The listener interface is wired and back-compat: a 
listener that doesn't declare `failure_details` keeps working unchanged 
(pluggy's name-based dispatch); a listener that declares it (without a default) 
gets `None` until an executor populates it.
   
   ## Repro
   
   ```bash
   pip install -e shared/listeners -e task-sdk -e airflow-core
   AIRFLOW__CORE__EXECUTOR=LocalExecutor airflow standalone &
   # trigger a DAG that fails
   airflow dags trigger e2e_failed
   # observe listener log
   tail /tmp/listener.log
   ```
   
   DAG used:
   
   ```python
   @dag(...)
   def e2e_failed_dag():
       @task(retries=0)
       def boom_task():
           raise ValueError("...")
       boom_task()
   
   class FailureDetailsListener:
       @hookimpl
       def on_task_instance_failed(self, previous_state, task_instance, error, 
failure_details):
           # failure_details is None until any executor populates it.
           ...
   ```
   
   
   ## Integrated mega-branch validation (all 7 PRs composed)
   
   This PR was independently validated, plus all seven PRs in this stack 
(#66394, #66395, #66397, #66399, #66402, #66405, #66410) were merged onto a 
single branch and exercised end-to-end through real services — `airflow 
standalone` running scheduler + API server + LocalExecutor + 
Postgres-equivalent (sqlite for the test). A single listener plugin declaring 
every new hook and parameter was registered, then 5 DAGs covering every 
state-transition path were triggered + a manual-set-state PATCH via the public 
API was issued. The listener log is below — every annotation maps a line to the 
PR that introduced it:
   
   ```
   running   prev=QUEUED    msg=started               task=ok_task              
                          ← PR-A msg arg
   success   prev=RUNNING   msg=success               task=ok_task              
                          ← PR-A
   running   prev=QUEUED    msg=started               task=boom_task
   failed    prev=RUNNING   msg=failed                task=boom_task   
error_type=ValueError   fd=None    ← PR-A + PR-D + PR-F kwarg
   running   prev=QUEUED    msg=started               task=skip_task
   skipped   prev=RUNNING   msg=skipped               task=skip_task            
                          ← PR-A skipped path
   running   prev=QUEUED    msg=started               task=retry_task
   failed    prev=RUNNING   msg=up_for_retry          task=retry_task  
error_type=ValueError              ← PR-A retry-vs-terminal
   running   prev=QUEUED    msg=started               task=retry_task    (try 2 
of 2)
   failed    prev=RUNNING   msg=failed                task=retry_task  
error_type=ValueError
   running   prev=QUEUED    msg=started               task=checkpoint_task
   checkpointed prev=RUNNING                          task=checkpoint_task  
checkpoint_data={'step': 5,
                                                                             
'iterator_offset': 1024}     ← PR-E + PR-G
   
   --- BEGIN MANUAL SET (PATCH /api/v2/.../taskInstances/ok_task 
new_state=failed) ---
   failed    prev=None      msg=manually_set_to_failed task=ok_task    
error_type=RuntimeError   fd=None  ← PR-D RuntimeError wrap
                                                                                
                             (would be `str` on the PR-A-only branch)
   ```
   
   What this validates jointly:
   
   | PR | Surface | Evidence in log |
   |---|---|---|
   | #66394 (msg arg) | every TI hook has `msg=...` | 6 canonical values fire 
(`started`, `success`, `failed`, `skipped`, `up_for_retry`, 
`manually_set_to_failed`) |
   | #66395 (hook-name log, TI) | logs identify the failing hook | tested 
separately with throwing listener — see PR body |
   | #66397 (hook-name log, rest) | lifecycle / DagRun / asset surfaces | 
tested separately with throwing listener — see PR body |
   | #66399 (tighten error type) | `error: BaseException \| None` | manual-set 
path delivers `RuntimeError` (was `str` on PR-A alone) |
   | #66402 (CHECKPOINTED state) | worker catches `AirflowTaskCheckpointed` | 
`running → checkpointed` transition observed at the listener and at the 
supervisor message boundary |
   | #66405 (FailureDetails) | listener can declare `failure_details` kwarg | 
`failure_details=None` flowing through every failure (no executor populates 
yet) |
   | #66410 (on_task_instance_checkpointed) | new hook fires with payload | 
`checkpointed task=checkpoint_task checkpoint_data={'step': 5, ...}` |
   
   ## Repro
   
   ```bash
   # Combine all 7 branches onto a mega branch (resolve trivial overlap on the
   # spec file's failure hook signature — error + msg + failure_details kwargs
   # in one signature) and install editable:
   pip install -e shared/listeners -e task-sdk -e airflow-core
   AIRFLOW__CORE__EXECUTOR=LocalExecutor airflow standalone &
   
   # Drop the recording listener (declares all 5 hooks including the new
   # checkpointed one) into $AIRFLOW_HOME/plugins/, drop 5 DAGs into dags/
   # (success / failed / skipped / retry-then-fail / checkpointed), trigger 
them.
   for dag in e2e_success e2e_failed e2e_skipped e2e_retry_then_fail 
e2e_checkpointed; do
     airflow dags trigger $dag
   done
   
   # Then PATCH a state via the public API to exercise the manual path.
   ```
   
   ## Bugs surfaced and fixed during this validation
   
   This step caught 6 bugs that the layer-2 unit-test pass missed — every fix 
is a separate commit on its respective PR's branch:
   
   - #66402: missing `AirflowTaskCheckpointed` import in `run()` (`NameError`)
   - #66402: task-sdk `_generated.py` `TaskInstanceState` missing CHECKPOINTED 
(`AttributeError`)
   - #66402: `TaskState` supervisor message Literal rejected CHECKPOINTED 
(Pydantic `ValidationError`)
   - #66402: task-sdk `_generated.py` `IntermediateTIState` missing CHECKPOINTED
   - #66405: pluggy default-eats-caller-value quirk — listener with 
`failure_details=None` default silently received None
   - #66405: every existing `on_task_instance_failed` call site missing 
`failure_details` kwarg (`HookCallError: hook call must provide argument`)
   
   Last two would have broken every task failure on apache/airflow `main` if 
the foundation PRs landed without the call-site fixes. The 
standalone-against-editable-install harness is a fast catch for this class.
   
   ## Documented gap (deliberately not fixed in this stack)
   
   `task-sdk/.../supervisor.py:STATES_SENT_DIRECTLY` lists the states the 
worker sends to the supervisor with a dedicated direct-send branch. 
CHECKPOINTED is not in that list, so it falls back to 
`client.task_instances.finish()` which the API server constrains to terminal 
states. The mega listener log shows the worker successfully logging `Task 
checkpointed; reporting CHECKPOINTED state.` and 
`on_task_instance_checkpointed` firing with the correct payload — but the DB 
row eventually transitions to `failed` because the supervisor cannot persist 
CHECKPOINTED through `finish()`. This is the AIP-96 design knob (auto-resume vs 
manual-resume-only) we deliberately want the discussion to settle, not silently 
pick. Documented in #66402.
   
   <!-- pr-triage-fold: triaged=2026-07-02T17:38:30Z head=57f338e action=draft 
-->
   
   ---
   
   > [!IMPORTANT]
   > **🛠️ Maintainer triage note for @1fanwang** · by `@potiuk` · 2026-07-02 
17:38 UTC
   >
   > Helpful heads-up from the maintainers — please address before this PR can 
be reviewed (see the [Pull Request quality 
criteria](https://github.com/apache/airflow/blob/main/contributing-docs/05_pull_requests.rst#pull-request-quality-criteria)):
   > - The following required checks are failing: `provider distributions tests 
/ Compat 2.11.1:P3.10:`, `provider distributions tests / Compat 3.0.6:P3.10:`, 
`provider distributions tests / Compat 3.1.8:P3.10:`, `provider distributions 
tests / Compat 3.2.1:P3.10:`. Please investigate and push a fix.
   >
   > **The ball is in your court** — you've been assigned to this PR. Fix the 
above, then mark it **Ready for review**.
   >
   > <sub>_Automated triage — may be imperfect; a maintainer takes the next 
look._</sub>
   
   <!-- /pr-triage-fold -->


-- 
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]

Reply via email to