Skip to content

Scheduler silently skips queued DagRuns forever when their pinned dag version can no longer be resolved #70056

Closed

Description

@kjh0623

Apache Airflow version

Observed in production on 3.1.8; the code path is unchanged on 3.3.0 and current main (b628e46).

What happened

A DagRun created by TriggerDagRunOperator sat in QUEUED for 8+ hours. The scheduler logged, on every loop:

DAG 'YB_DUMMY' not found in serialized_dag table

while the DAG itself was healthy: not paused, no import errors, visible in the UI, and new runs of the same DAG worked. Run-scoped API endpoints for the stuck run returned 404 while latest-version endpoints returned 200. The parent DAG's ExternalTaskSensor treats only success/failed as terminal, so it poked the queued run until its own 10h timeout.

The chain:

  1. The run was created pinned: bundle_version set, created_dag_version_id pointing at the dag version current at creation time. The FK is declared ondelete="set null" (dagrun.py L301-303).
  2. The pinned dag_version row was later deleted (the FK's SET NULL exists precisely because version rows can be deleted). created_dag_version_id became NULL silently; bundle_version stayed set.
  3. DBDagBag._version_from_dag_run (dagbag.py L211-216) only falls back to the latest version when bundle_version is not set. For a pinned run it returns created_dag_version_id — now NULL — so get_dag_for_run() returns None.
  4. _start_queued_dagruns handles that with log.error(...); continue (scheduler_job_runner.py, "DAG '%s' not found in serialized_dag table"). The run is neither started nor failed, and is re-selected and re-skipped on every subsequent loop, forever. No timeout applies to a QUEUED run in this state.

So a single race between "run created & pinned" and "pinned version row deleted" produces a run that is permanently stuck with no failure signal, no metric, and an error message that points at the wrong cause (the DAG is in the serialized_dag table — just not the pinned version).

What you think should happen instead

The scheduler already handles the sibling case for task instances in the same function: when the serialized dag for a SCHEDULED TI cannot be found, the TIs are set to FAILED and the loop moves on. Queued runs deserve the same explicit outcome. Options, in my order of preference:

  1. Fail the run explicitly when its pinned version cannot be resolved (message naming the pinned created_dag_version_id/bundle_version), consistent with the TI path.
  2. Fall back to the latest serialized version, matching unpinned behavior — simpler for users, but arguably violates version-pinning semantics.
  3. At minimum, emit a dedicated metric/log so the permanent skip is observable.

I'm happy to submit a PR for option 1 (or whichever direction maintainers prefer).

How to reproduce

Two tests against current main — both pass (verified on Linux CI at b628e46; the first one "passing" is the bug: the run stays QUEUED through repeated scheduler loops). Branch with the tests: https://github.com/kjh0623/airflow/tree/fix/queued-dagrun-stuck-unresolvable-version

def test_queued_dagrun_with_unresolvable_pinned_version_is_stuck_forever(self, dag_maker, session):
    with dag_maker(dag_id="test_stuck_pinned_run"):
        EmptyOperator(task_id="mytask")
    dr = dag_maker.create_dagrun(run_type=DagRunType.MANUAL, state=State.QUEUED)

    # The run was created pinned to a bundle version...
    dr.bundle_version = "0123456789abcdef"
    # ...and the pinned dag_version row has since been deleted -> FK SET NULL
    dr.created_dag_version_id = None
    session.merge(dr)
    session.flush()

    self.job_runner = SchedulerJobRunner(job=Job(), executors=[self.null_exec])
    for _ in range(3):
        self.job_runner._start_queued_dagruns(session)
        session.flush()

    dr = session.scalars(select(DagRun).where(DagRun.dag_id == "test_stuck_pinned_run")).one()
    assert dr.state == State.QUEUED  # skipped every loop: never started, never failed

def test_queued_dagrun_without_bundle_version_falls_back_to_latest(self, dag_maker, session):
    """Contrast: same NULL created_dag_version_id, but unpinned -> falls back to latest and starts."""
    with dag_maker(dag_id="test_unpinned_run_falls_back"):
        EmptyOperator(task_id="mytask")
    dr = dag_maker.create_dagrun(run_type=DagRunType.MANUAL, state=State.QUEUED)
    dr.bundle_version = None
    dr.created_dag_version_id = None
    session.merge(dr)
    session.flush()

    self.job_runner = SchedulerJobRunner(job=Job(), executors=[self.null_exec])
    self.job_runner._start_queued_dagruns(session)
    session.flush()

    dr = session.scalars(select(DagRun).where(DagRun.dag_id == "test_unpinned_run_falls_back")).one()
    assert dr.state == State.RUNNING

On a live deployment: trigger a run into QUEUED (e.g. via TriggerDagRunOperator with wait_for_completion=False under max_active_runs pressure), then delete its dag_version row; the run stays QUEUED indefinitely with the log line above.

Operating System

Kubernetes (observed); reproduction is OS-independent.

Deployment

Official Docker image on Kubernetes, KubernetesExecutor, git-synced bundle (bundle_version populated on run creation).

Anything else

Recovery in production required manually clearing the run so a freshly pinned run could be created. SQL to confirm the state:

SELECT run_id, state, bundle_version, created_dag_version_id
FROM dag_run
WHERE state = 'queued' AND bundle_version IS NOT NULL AND created_dag_version_id IS NULL;

Willing to submit PR?

  • Yes I am willing to submit a PR!

Code of Conduct

Activity

  1. boring-cyborg commented on Jul 17, 2026

    @boring-cyborg

    Thanks for opening your first issue here! Be sure to follow the issue template! If you are willing to raise PR to address this issue please do so, no need to wait for approval.

  2. added
    kind:bugThis is a clearly a bug
    priority:highHigh priority bug that should be patched quickly but does not require immediate new release
    on Jul 17, 2026
  3. souvikDevloper commented on Jul 19, 2026

    @souvikDevloper

    @uranusjr shall i look into it or what u feel all hard work done?

  4. kjh0623 commented on Jul 20, 2026

    @kjh0623
    Author

    I've opened #70113 with option 1 (fail the run explicitly when its pinned version can't be resolved, mirroring the existing SCHEDULED-TI path in the same function), plus the two tests from my repro branch adapted to assert the fixed behavior (FAILED instead of stuck QUEUED, and the unpinned contrast still falling back to latest).

    @souvikDevloper thanks for the offer! Since I'd already root-caused it and had the repro tests, I went ahead and put up the PR — but a review on it would be very welcome.

  5. added a commit that references this issue on Jul 21, 2026
    e751133
  6. added a commit that references this issue on Aug 20, 2026
    687effc
  7. kjh0623 commented on Oct 14, 2026

    @kjh0623
    Author

    Closing this as resolved by #71696 (released in 3.3.2).

    DBDagBag._version_from_dag_run now falls back to the latest Dag version when created_dag_version_id is missing, even if bundle_version is set:

    if not dag_run.bundle_version or not dag_run.created_dag_version_id:

    So a queued run whose pinned version was deleted now resolves to the latest version and starts, instead of being skipped on every scheduler loop. That's option 2 from the issue description. As @kaxil noted when closing #70113, failing the run explicitly (option 1) isn't the preferred direction.

    If you're on a version before 3.3.2, this query finds runs stuck in this state:

    SELECT dag_id, run_id, bundle_version
    FROM dag_run
    WHERE state = 'queued'
      AND bundle_version IS NOT NULL
      AND created_dag_version_id IS NULL;

    Clearing those runs, or upgrading to 3.3.2+, gets them moving again.

    Changing the dag_run.created_dag_version_id FK from SET NULL to RESTRICT (discussed in #70072) would be a separate hardening change. It isn't needed to fix this bug.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    area:corearea:schedulerkind:bugThis is a clearly a bugpriority:highHigh priority bug that should be patched quickly but does not require immediate new release

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions