manipatnam opened a new pull request, #72054:
URL: https://github.com/apache/airflow/pull/72054

   # Fix cleared backfill DAG runs getting stuck in `queued` state
   
   ## Problem
   
   A DAG run that belongs to a **cancelled backfill** can get permanently stuck 
in the `queued` state. The scheduler never promotes it to `running`, no matter 
how much capacity is available, and clearing it repeatedly does not help.
   
   ### Root cause
   
   Cancelling a backfill (`PUT /backfills/{id}/cancel`) does two things to the 
`Backfill` row:
   - sets `is_paused = True`
   - sets `completed_at` to the cancel time
   
   The scheduler's promotion query, 
`DagRun.get_queued_dag_runs_to_set_running`,decides which queued runs to move 
to `running`. It filters out any run whose backfill is paused:
   
   ```python
   # don't set paused dag runs as running
   not_(coalesce(cast("ColumnElement[bool]", Backfill.is_paused), False)),
   ```
   
   This filter looks only at `is_paused` and ignores `completed_at`. That is 
correct while a backfill is *live* (a user pauses it temporarily and expects 
its runs to stop starting). But once a backfill is **completed/cancelled**, 
`is_paused` is a stale flag that is never cleared — so any queued run still 
associated with that backfill is skipped forever.
   
   A run reaches this state through a normal, reasonable sequence:
   
   1. A backfill reprocesses an existing run (the run gets `backfill_id` set 
and `run_type = backfill`).
   2. The user cancels the backfill → `is_paused = True`, `completed_at` set.
   3. The user later clears that run to re-run it → the run returns to 
`queued`,still associated with the now-cancelled backfill.
   4. The scheduler skips it on every loop because `is_paused = True`. Stuck.
   
   `last_scheduling_decision` stays `NULL` on the stuck run, confirming the 
scheduler never even evaluates it.
   
   ## Fix
   
   Treat `is_paused` as meaningful only while the backfill is still running. A 
run whose backfill has already completed must remain schedulable:
   
   ```python
   # don't set paused dag runs as running, but a completed backfill's
   # is_paused flag is stale: a run cleared after the backfill was
   # cancelled (cancel sets is_paused=True and completed_at) must still
   # be schedulable, otherwise it is stuck in queued forever.
   or_(
       Backfill.completed_at.isnot(None),
       not_(coalesce(cast("ColumnElement[bool]", Backfill.is_paused), False)),
   ),
   ```
   
   This is the smallest possible change and targets the exact defective 
assumption. Because the fix operates purely on the promotion query, 
**previously stranded runs recover automatically** on the next scheduler loop — 
no manual DB intervention or re-clearing is required.
   
   ## Reproduction
   
   Minimal DAG:
   
   ```python
   from airflow.sdk import DAG, chain
   from airflow.providers.standard.operators.python import PythonOperator
   from pendulum import datetime
   
   with DAG(
       dag_id="repro_backfill_clear_stuck",
       schedule="@daily",
       start_date=datetime(2026, 7, 1),
       catchup=False,
       max_active_runs=1,
   ) as dag:
       a = PythonOperator(task_id="a", python_callable=lambda: None)
       b = PythonOperator(task_id="b", python_callable=lambda: None)
       chain(a, b)
   ```
   
   Steps:
   
   1. Unpause the DAG and let a scheduled run complete.
   2. Create a backfill for a past interval:
      ```bash
      airflow backfill create --dag-id repro_backfill_clear_stuck \
        --from-date "2026-08-01T00:00:00+00:00" \
        --to-date   "2026-08-02T00:00:00+00:00"
      ```
   3. **Cancel the backfill** (from the UI or API). This sets `is_paused = 
True` and `completed_at` on the backfill.
   4. Mark the backfill's DAG run as **failed**, then **clear** it.
   5. Observe the run returns to `queued` and never progresses. Its 
`last_scheduling_decision` stays `NULL`.
   
   Verify the stuck state:
   
   ```sql
   SELECT dr.run_id, dr.state, dr.backfill_id, b.is_paused, b.completed_at
   FROM dag_run dr
   JOIN backfill b ON b.id = dr.backfill_id
   WHERE dr.dag_id = 'repro_backfill_clear_stuck'
     AND dr.state = 'queued';
   ```
   
   > Note: cancelling is required to reproduce. A backfill that completes 
*normally*leaves `is_paused = False`, so its cleared    > runs are promoted and 
this bug does not surface.
   
   With the fix applied, the run is promoted to `running` on the next scheduler 
loop.
   
   ## Tests
   
   Added 
`test_backfill_runs_started_when_backfill_completed_despite_paused_flag` 
in`airflow-core/tests/unit/jobs/test_scheduler_job.py`, next to the existing 
`test_backfill_runs_not_started_when_backfill_paused`. It creates a backfill, 
simulates a cancel (`is_paused = True` + `completed_at`), runs 
`_start_queued_dagruns`, and asserts the runs are promoted (up to the 
backfill's `max_active_runs`).
   
   - Passes with the fix.
   - **Fails without the fix** (0 runs promoted instead of 3), confirming it is 
a genuine regression test.
   
   The sibling `test_backfill_runs_not_started_when_backfill_paused` still 
passes, confirming an *active* (non-completed) paused backfill continues to 
hold its runs.
   
   ## Behavior notes and limitations
   
   - **The run remains a backfill run.** The fix only changes promotion 
eligibility; it does not alter `run_type`, `backfill_id`, or the 
`BackfillDagRun` association. The run keeps its lower (`sort_ordinal`) 
scheduling priority and remains governed by the backfill's `max_active_runs`.
   - **The run's own data is recorded correctly.** State, start/end dates, 
duration, task instances, and any data the DAG produces are written normally 
when it runs.
   - **Backfill aggregate metadata is not refreshed.** The parent 
`Backfill.completed_at` stays frozen at the cancel time and 
`_mark_backfills_complete` never revisits an already-completed backfill. So a 
run that executes after the backfill was cancelled produces a reporting 
inconsistency: the backfill shows completed earlier than the actual run. This 
is a metadata/reporting artifact, not a data-integrity problem for the DAG's 
outputs.
   
   ## Alternatives considered
   
   - **Detach the run from the terminal backfill on clear** (set `backfill_id = 
NULL`, drop the `BackfillDagRun` row, revert `run_type`). Cleaner lineage 
semantics and avoids the stale-`completed_at` inconsistency, but touches the 
hot `clear_task_instances` path and discards the "this interval was part of 
backfill N" lineage.
   - **Block clearing runs of completed/cancelled backfills** (return `409`). 
Prevents new orphans but is a breaking UX change and does not recover 
already-stuck runs.
   
   Option A (this PR) was chosen for its minimal blast radius and automatic 
recovery of existing stuck runs. Happy to switch direction if maintainers 
prefer detaching the run or refreshing the backfill's `completed_at` when a run 
becomes eligible again.
   


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