Skip to content

UI: "Next Run" on paused DAGs drifts forward each parse cycle but stays in the past #66462

Description

@1fanwang

Apache Airflow version

3.x (verified against main at HEAD on 2026-05-06; same mechanism present in 2.9.x)

What happened?

When a DAG with catchup=False is paused, the "Next Run" value displayed in the UI (and returned by the FastAPI /api/v2/dags/{dag_id}/details next_dagrun_logical_date / next_dagrun_run_after) is recomputed each parse cycle against the current wall-clock time. The result is that the displayed value moves forward by one cron period each day, but stays in the past relative to "now". To a user, this looks like the scheduler is broken — the date keeps changing daily, but never advances to a future time.

Concrete example with cron 0 1 * * * (one daily run, catchup=False):

When (UTC) DAG state UI "Next Run" shows User's reading
Day 0, 23:30 unpaused, prior run terminal Day +1, 08:00 (future) ✓ "tomorrow"
Day 0, 23:39 user pauses Day +1, 08:00 (still future at this moment) ✓ ok
Day 1, 22:40 parse runs while paused Day 0, 08:00 (now in past) ⚠ "stuck?"
Day 2, 22:40 parse runs while paused Day 1, 08:00 (still in past) ⚠ "drifting"
Day 3, 22:40 parse runs while paused Day 2, 08:00 (still in past) ❌ "broken"

The displayed date strictly increases by one cron period per day, but the user's now increases by the same amount, so the gap between "now" and "Next Run" never closes — the value is permanently in the past while the DAG remains paused. This often gets escalated as a scheduling regression before the user discovers that the DAG is simply paused.

What you think should happen instead?

For paused DAGs, "Next Run" is not predictive of when the next run will fire — the scheduler will not materialize any run while is_paused=True. The current display is therefore misleading.

Three fix options, increasing in invasiveness:

(A) UI-only. When is_paused=True, render "Next Run" as or Will run on unpause: <logical_date> (with a tooltip clarifying that the value is the interval that will fire on unpause, not a scheduled future time). Lowest-risk option, addresses the user-facing confusion entirely. Touches airflow-core/src/airflow/ui/src/pages/DagsList/DagCard.tsx:102-108 and the equivalent DAG-details view.

(B) FastAPI shape change. Set next_dagrun_logical_date / next_dagrun_run_after to null (or to a separate next_dagrun_on_unpause field) when is_paused=True. Cleaner contract but breaks any external API consumer that reads those fields on paused DAGs. Touches airflow-core/src/airflow/api_fastapi/core_api/datamodels/dags.py.

(C) Scheduler short-circuit. Skip calculate_dagrun_date_fields in airflow-core/src/airflow/dag_processing/collection.py:638 when is_paused=True. Stops the rolling-forward computation at the source. Most invasive — would freeze next_dagrun_* at the value they had when the user paused; the existing "fires the missed interval immediately on unpause" behavior would need a recompute path on unpause to be preserved.

(A) is the recommended fix — minimum surface, no behavior change, just a UI guard with a clearer label.

How to reproduce

  1. Define a DAG with catchup=False and a daily cron, e.g. 0 1 * * *. Start the scheduler.
  2. Wait for one scheduled run to complete. Confirm the UI shows "Next Run" at the next scheduled time (e.g., tomorrow 1 AM).
  3. Pause the DAG.
  4. Wait at least one full DAG-parse cycle past the next scheduled time (default scheduler dag_dir_list_interval=300s; for a clearer effect wait until the next day).
  5. Refresh the DAGs list page.

Observed: "Next Run" displays a date in the past. Each subsequent day the date moves forward by one cron period but remains in the past.

Expected: the UI does not display a misleading past-date "Next Run" while the DAG is paused.

Operating System

N/A — logic bug, not OS-specific

Versions of Apache Airflow Providers

N/A

Deployment

Other

Deployment details

Reproduces with airflow standalone on main HEAD as of 2026-05-06.

Anything else?

Source-code trace (verified against apache/airflow main at 2026-05-06)

The mechanism is unchanged from 2.9.x → 3.x:

  1. Parse path always runs calculate_dagrun_date_fields, regardless of pause state. airflow-core/src/airflow/dag_processing/collection.py:563-638update_dags() iterates all DAGs and calls dm.calculate_dagrun_date_fields(dag, last_automated_run=last_automated_run) at line 638. There is no is_paused short-circuit anywhere in this loop.

  2. calculate_dagrun_date_fields calls next_dagrun_info which routes to the timetable's _skip_to_latest for catchup=False. airflow-core/src/airflow/models/dag.py:757-800 calls dag.next_dagrun_info(...). The TODO at dag.py:769 already references the existing meta-bug calculate_dagrun_date_fields doesn't understand how it is called #59618 about the helper's contract.

  3. _skip_to_latest recomputes against utcnow() on every call. airflow-core/src/airflow/timetables/interval.py:156-178 (CronDataIntervalTimetable). Concrete trace at current_time = 2026-05-04T22:40:22+00:00 for cron 0 1 * * *:

    current_time   = 5-4 22:40 UTC
    last_start     = _get_prev(5-4 22:40)             = 5-4 08:00 UTC
    next_start     = _get_next(5-4 08:00)             = 5-5 08:00 UTC
    # next_start > current_time, so:
    new_start      = _get_prev(last_start)            = 5-3 08:00 UTC
    

    Run the same code one day later at 5-5 22:40 UTC and new_start becomes 5-4 08:00 UTC — i.e., the value rolls forward by one cron period each day. The RoundedDataIntervalTimetable._skip_to_latest (interval.py:237-250) has the same utcnow()-based recomputation.

  4. FastAPI returns these fields unchanged for paused DAGs. airflow-core/src/airflow/api_fastapi/core_api/datamodels/dags.py:84-108is_paused: bool and next_dagrun_logical_date / next_dagrun_data_interval_start / next_dagrun_data_interval_end / next_dagrun_run_after are all serialized as independent fields with no conditional logic between them.

  5. React UI renders "Next Run" with no is_paused check. airflow-core/src/airflow/ui/src/pages/DagsList/DagCard.tsx:102-108:

    <Stat data-testid="next-run" label={translate("dagDetails.nextRun")}>
      {Boolean(dag.next_dagrun_run_after) ? (
        <DagRunInfo
          logicalDate={dag.next_dagrun_logical_date}
          runAfter={dag.next_dagrun_run_after as string}
        />
      ) : undefined}
    </Stat>

    The only is_paused check in this file (line 95) is on the spinner for the Latest Run — Next Run has no equivalent guard.

Relationship to existing issues

Scope notes

  • Specific to catchup=False. With catchup=True, the timetable does not call _skip_to_latest, so the rolling-forward effect doesn't manifest the same way (paused-then-unpaused with catchup=True would attempt backfill of all missed intervals — different user-facing behavior).
  • The same display issue affects next_dagrun_data_interval_start and next_dagrun_data_interval_end.
  • The mechanism is correct for unpaused DAGs: it's how catchup=False knows to fire the most recent missed interval on schedule resumption. The problem is purely the UX of surfacing the rolling-forward value in a state where it's not predictive.
  • Adjacent: any DAG where run materialization is blocked while parsing continues (paused, max_active_runs exhausted, deactivated) will have the same display drift. Paused is the cleanest case to fix first; the others can follow.

Are you willing to submit PR?

  • Yes I am willing to submit a PR (Option A, UI-only)

Code of Conduct

Metadata

Metadata

Assignees

No one assigned

    Labels

    area:UIRelated to UI/UX. For Frontend Developers.kind:bugThis is a clearly a bugpriority:mediumBug that should be fixed before next release but would not block a release

    Type

    No type

    Fields

    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions