This is an automated email from the ASF dual-hosted git repository.

ashb pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/airflow.git


The following commit(s) were added to refs/heads/main by this push:
     new 1e404fac4ac Stop the scheduler crashing when a queued run's Dag fails 
to deserialize (#74499)
1e404fac4ac is described below

commit 1e404fac4ace22467a2e3680523c2fd0d60b3d19
Author: Adam Postek <[email protected]>
AuthorDate: Fri Oct 9 15:22:16 2026 +0000

    Stop the scheduler crashing when a queued run's Dag fails to deserialize 
(#74499)
    
    A queued run whose Dag cannot be deserialized (for example a custom 
timetable
    calling Variable.get on a missing Variable) raised out of 
_start_queued_dagruns
    and crashed the scheduler, which then crashed again on every restart. Log 
and
    skip that run instead, as _schedule_all_dag_runs already does for running 
runs.
---
 airflow-core/newsfragments/74499.bugfix.rst        |  1 +
 .../src/airflow/jobs/scheduler_job_runner.py       |  8 ++++-
 airflow-core/tests/unit/jobs/test_scheduler_job.py | 40 +++++++++++++++++++++-
 3 files changed, 47 insertions(+), 2 deletions(-)

diff --git a/airflow-core/newsfragments/74499.bugfix.rst 
b/airflow-core/newsfragments/74499.bugfix.rst
new file mode 100644
index 00000000000..c32f1614959
--- /dev/null
+++ b/airflow-core/newsfragments/74499.bugfix.rst
@@ -0,0 +1 @@
+Fix the scheduler crashing, and crashing again on every restart, when a queued 
Dag run's Dag cannot be deserialized, for example because a custom timetable 
reads a missing Variable. The run is now logged and skipped until its Dag loads.
diff --git a/airflow-core/src/airflow/jobs/scheduler_job_runner.py 
b/airflow-core/src/airflow/jobs/scheduler_job_runner.py
index 8bd4085a7fe..b13ceefb9a1 100644
--- a/airflow-core/src/airflow/jobs/scheduler_job_runner.py
+++ b/airflow-core/src/airflow/jobs/scheduler_job_runner.py
@@ -3052,7 +3052,13 @@ class SchedulerJobRunner(BaseJobRunner, LoggingMixin):
             dag_id = dag_run.dag_id
             run_id = dag_run.run_id
             backfill_id = dag_run.backfill_id
-            dag = dag_run.dag = cached_get_dag(dag_run)
+            try:
+                dag = dag_run.dag = cached_get_dag(dag_run)
+            except DBAPIError:
+                raise
+            except Exception:
+                self.log.exception("Failed to load Dag %s for queued run %s, 
skipping it", dag_id, run_id)
+                continue
             if not dag:
                 self.log.error("DAG '%s' not found in serialized_dag table", 
dag_run.dag_id)
                 continue
diff --git a/airflow-core/tests/unit/jobs/test_scheduler_job.py 
b/airflow-core/tests/unit/jobs/test_scheduler_job.py
index 8a128fba211..0633e4d2d5f 100644
--- a/airflow-core/tests/unit/jobs/test_scheduler_job.py
+++ b/airflow-core/tests/unit/jobs/test_scheduler_job.py
@@ -58,7 +58,7 @@ from airflow.callbacks.callback_requests import (
 from airflow.callbacks.database_callback_sink import DatabaseCallbackSink
 from airflow.dag_processing.collection import AssetModelOperation, 
DagModelOperation
 from airflow.dag_processing.dagbag import DagBag, sync_bag_to_db
-from airflow.exceptions import AirflowException, TaskNotFound
+from airflow.exceptions import AirflowException, DeserializationError, 
TaskNotFound
 from airflow.executors.base_executor import BaseExecutor
 from airflow.executors.executor_constants import MOCK_EXECUTOR
 from airflow.executors.executor_loader import ExecutorLoader
@@ -7070,6 +7070,44 @@ class TestSchedulerJob:
                 for msg in error_messages
             )
 
+    def test_start_queued_dagruns_does_not_crash_on_dag_deserialization_error(
+        self, dag_maker, caplog, session
+    ):
+        """A queued run whose Dag cannot be deserialized must not crash the 
scheduler.
+
+        A custom timetable that raises in ``deserialize`` (for example 
``Variable.get`` on a missing
+        Variable) used to escape ``_start_queued_dagruns`` into the scheduler 
loop, so every restart hit
+        the same queued run again and no Dag was scheduled at all.
+        """
+        with dag_maker(dag_id="bad_dag", schedule="@once"):
+            EmptyOperator(task_id="bad_task")
+        dag_maker.create_dagrun(state=DagRunState.QUEUED)
+
+        with dag_maker(dag_id="good_dag", schedule="@once"):
+            EmptyOperator(task_id="good_task")
+        dag_maker.create_dagrun(state=DagRunState.QUEUED)
+        session.flush()
+
+        self.job_runner = SchedulerJobRunner(job=Job(), 
executors=[self.null_exec])
+        get_dag_for_run = self.job_runner.scheduler_dag_bag.get_dag_for_run
+
+        def fail_for_bad_dag(dag_run, session):
+            if dag_run.dag_id == "bad_dag":
+                raise DeserializationError("bad_dag") from 
RuntimeError("Variable not found")
+            return get_dag_for_run(dag_run=dag_run, session=session)
+
+        caplog.clear()
+        with (
+            caplog.at_level("ERROR", 
logger="airflow.jobs.scheduler_job_runner"),
+            patch.object(self.job_runner.scheduler_dag_bag, "get_dag_for_run", 
side_effect=fail_for_bad_dag),
+        ):
+            self.job_runner._start_queued_dagruns(session)
+        session.flush()
+
+        states = dict(session.execute(select(DagRun.dag_id, 
DagRun.state)).all())
+        assert states == {"good_dag": DagRunState.RUNNING, "bad_dag": 
DagRunState.QUEUED}
+        assert any(r.levelno >= logging.ERROR and "bad_dag" in r.getMessage() 
for r in caplog.records)
+
     def test_schedule_all_dag_runs_reraises_db_errors(self, dag_maker, 
session):
         """Test that _schedule_all_dag_runs does not catch DBAPIError, allowing
         it to propagate to @retry_db_transaction for proper retry handling.

Reply via email to