Repository navigation
Scheduler silently skips queued DagRuns forever when their pinned dag version can no longer be resolved #70056
Description
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:
- The run was created pinned:
bundle_versionset,created_dag_version_idpointing at the dag version current at creation time. The FK is declaredondelete="set null"(dagrun.py L301-303). - The pinned
dag_versionrow was later deleted (the FK'sSET NULLexists precisely because version rows can be deleted).created_dag_version_idbecame NULL silently;bundle_versionstayed set. DBDagBag._version_from_dag_run(dagbag.py L211-216) only falls back to the latest version whenbundle_versionis not set. For a pinned run it returnscreated_dag_version_id— now NULL — soget_dag_for_run()returnsNone._start_queued_dagrunshandles that withlog.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:
- 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. - Fall back to the latest serialized version, matching unpinned behavior — simpler for users, but arguably violates version-pinning semantics.
- 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.RUNNINGOn 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
- I agree to follow this project's Code of Conduct
Activity
boring-cyborg commentedon Jul 17, 2026
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.
souvikDevloper commentedon Jul 19, 2026
@uranusjr shall i look into it or what u feel all hard work done?
kjh0623 commentedon Jul 20, 2026
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 (
FAILEDinstead of stuckQUEUED, 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.
Fail queued DagRuns with an unresolvable pinned dag version Closes: a…
Fail queued DagRuns with an unresolvable pinned dag version Closes: a…
kjh0623 commentedon Oct 14, 2026
Closing this as resolved by #71696 (released in 3.3.2).
DBDagBag._version_from_dag_runnow falls back to the latest Dag version whencreated_dag_version_idis missing, even ifbundle_versionis set: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:
Clearing those runs, or upgrading to 3.3.2+, gets them moving again.
Changing the
dag_run.created_dag_version_idFK fromSET NULLtoRESTRICT(discussed in #70072) would be a separate hardening change. It isn't needed to fix this bug.