ChickenBenny opened a new issue, #76190:
URL: https://github.com/apache/airflow/issues/76190

   ### Under which category would you file this issue?
   
   Airflow Core
   
   ### Apache Airflow version
   
   main (`d561f37ea5`, 2026-10-29); the same code path is present in 3.3.2
   
   ### What happened and how to reproduce it?
   
   In a Dag with `fail_fast=True`, if a task is removed from the Dag (deleted 
or renamed) while a run is in progress, the next task in that run that finally 
fails triggers a `KeyError` in `_stop_remaining_tasks`. This happens twice:
   
   1. **Execution API** – the worker's `PATCH 
/execution/task-instances/{id}/state` (FAILED) returns 500. The catch-all in 
`ti_update_state` calls `_handle_fail_fast_for_dag` again and hits the same 
`KeyError`, so the TI is never marked FAILED.
   2. **Scheduler** – once the worker gives up, the executor reports the task 
as failed, the scheduler sees a state mismatch and calls `ti.handle_failure()`, 
which runs fail_fast again and raises the same `KeyError`. Nothing catches it, 
so `_run_scheduler_loop` exits and **the scheduler process dies**.
   
   When the scheduler is restarted (k8s/systemd/supervisor), it resets the 
still-RUNNING TI as orphaned and schedules a new try. The task fails again and 
the cycle repeats indefinitely.
   
   **Where it breaks**
   
   `_stop_remaining_tasks` (`airflow-core/src/airflow/models/taskinstance.py`) 
iterates over every TI of the run in the DB and looks each one up in the Dag:
   
   ```python
   for ti in tis:
       if ti.task_id == task_instance.task_id or ti.state in (SUCCESS, FAILED):
           continue
       if task_teardown_map:
           teardown = task_teardown_map[ti.task_id]                          # 
Execution API path
       else:
           teardown = task_instance.task.dag.task_dict[ti.task_id].is_teardown  
# scheduler path
   ```
   
   For an unversioned bundle (e.g. the default local Dags folder), 
`DBDagBag._version_from_dag_run` resolves the run to the **latest** Dag 
version. If a task was removed or renamed while the run was active, it is not 
in that version, but its TI is still in the run. That TI can be `REMOVED` (the 
scheduler marks it in `_filter_tis_and_exclude_removed`) or still in any 
unfinished state if the scheduler has not got to it yet. Either way it passes 
the state check above, so the lookup raises `KeyError`. The trigger is the task 
missing from the resolved Dag version, not the `REMOVED` state itself.
   
   **A realistic way to hit this:** a long-running `fail_fast` Dag is in 
progress when CI/CD deploys a new version of the Dag file that renames or 
removes one of its tasks. Any task in that run that later fails for good brings 
the scheduler down.
   
   **Steps to reproduce** (breeze, `--backend postgres --executor 
LocalExecutor`; any deployment with an unversioned bundle should behave the 
same)
   
   Put this Dag in the Dags folder. To make the repro deterministic, the Dag 
simulates the deployment from inside the run: `boom` creates a flag file that 
drops `victim` from the next parse, waits for that re-parse, then fails:
   
   ```python
   import pathlib
   import time
   
   from airflow.sdk import DAG, task
   
   FLAG = pathlib.Path(__file__).with_name("remove_victim")
   
   with DAG(dag_id="fail_fast_removed_task", schedule=None, fail_fast=True, 
catchup=False):
   
       @task(retries=0)
       def boom():
           FLAG.touch()
           time.sleep(25)  # longer than [dag_processor] 
min_file_process_interval
           raise RuntimeError("boom")
   
       boom()
   
       if not FLAG.exists():
   
           @task
           def victim():
               time.sleep(300)
   
           victim()
   ```
   
   1. Make sure `remove_victim` does not exist and the Dag has been parsed with 
both tasks (set `[dag_processor] min_file_process_interval` to 10 to speed 
things up).
   2. Trigger the Dag.
   3. About 30 seconds later, `boom` fails.
   
   **Observed**
   
   API server:
   
   ```
   Error updating Task Instance state. Setting the task to failed.
   ...
   KeyError: 'victim'
   ```
   
   Worker (task log), followed by `Workload execution failed.`:
   
   ```
   Starting call to 'airflow.sdk.api.client.Client._request_with_retry', this 
is the 1st time calling it.
   ...
   Starting call to 'airflow.sdk.api.client.Client._request_with_retry', this 
is the 4th time calling it.
   ```
   
   Scheduler:
   
   ```
   [error] Executor LocalExecutor(parallelism=32) reported that the task 
instance <TaskInstance: ...boom ... [running]> finished with state failed, but 
the task instance's state ...
   [error] Exception when executing SchedulerJob._run_scheduler_loop
   Traceback (most recent call last):
     File ".../airflow/jobs/scheduler_job_runner.py", line 1884, in _execute
     File ".../airflow/jobs/scheduler_job_runner.py", line 2103, in 
_run_scheduler_loop
     File ".../airflow/jobs/scheduler_job_runner.py", line 1470, in 
_process_executor_events
     File ".../airflow/jobs/scheduler_job_runner.py", line 1829, in 
process_executor_events
       ti.handle_failure(error=msg, session=session)
     File ".../airflow/models/taskinstance.py", line 2809, in handle_failure
     File ".../airflow/models/taskinstance.py", line 2757, in 
fetch_handle_failure_context
       _stop_remaining_tasks(task_instance=ti, session=session)
     File ".../airflow/models/taskinstance.py", line 221, in 
_stop_remaining_tasks
       teardown = task_instance.task.dag.task_dict[ti.task_id].is_teardown
   KeyError: 'victim'
   Shutting down LocalExecutor; waiting for running tasks to finish.
   ```
   
   After the scheduler restarts:
   
   ```
   Reset the following 1 orphaned TaskInstances:
       <TaskInstance: ...boom ... [running]>
   Setting the following tasks to queued state: ... boom ... (try_number=3)
   ```
   
   Left running for about 30 minutes, `boom` (`retries=0`, `max_tries=0`) 
reached **try 13**, every scheduler job from that period ended `failed`, and 
the Dag run stayed `running`.
   
   
   ### What you think should happen instead?
   
   `boom` should be marked FAILED once, `victim` should be left alone, and the 
Dag run should end as failed.
   
   ### Operating System
   
   _No response_
   
   ### Deployment
   
   None
   
   ### Apache Airflow Provider(s)
   
   _No response_
   
   ### Versions of Apache Airflow Providers
   
   _No response_
   
   ### Official Helm Chart version
   
   Not Applicable
   
   ### Kubernetes Version
   
   _No response_
   
   ### Helm Chart configuration
   
   _No response_
   
   ### Docker Image customizations
   
   _No response_
   
   ### Anything else?
   
   - The Execution API part (`task_teardown_map[ti.task_id]` and the catch-all 
re-calling `_handle_fail_fast_for_dag`) is the same in 3.3.2. The scheduler 
part (`task_dict[ti.task_id]`, `ti.handle_failure` in `process_executor_events` 
with no surrounding handler, and the latest-version fallback in 
`_version_from_dag_run`) is also the same in 3.3.2. This is from reading the 
3.3.2 source; I reproduced it only on `main`.
   - On the API side, every supervisor path that reports a final FAILED goes 
through the same handler: the task raising, the task process dying without a 
message (state inferred from the exit code), or the task runtime failing to 
start.
   - The scheduler reaches `_stop_remaining_tasks` from two places, both via 
`ti.handle_failure`. One is `process_executor_events`, reproduced above. The 
other is the heartbeat-timeout purge 
(`_find_and_purge_task_instances_without_heartbeats` → 
`_purge_task_instances_without_heartbeats`), which loads the task the same way 
so that fail_fast applies, and runs in a timer registered without 
`non_fatal=True`. A worker that disappears without reporting (node loss, 
network partition) would therefore likely crash the scheduler the same way, 
without going through the Execution API first. Not reproduced yet.
   - Versioned bundles should not be affected because the run stays on its own 
Dag version, which still has the task. Not verified.
   
   ### Are you willing to submit PR?
   
   - [x] Yes I am willing to submit a PR!
   
   ### Code of Conduct
   
   - [x] I agree to follow this project's [Code of 
Conduct](https://github.com/apache/airflow/blob/main/CODE_OF_CONDUCT.md)
   


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