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 1d3d8f533fb Catch lazy timetable errors when starting queued runs 
(#76288)
1d3d8f533fb is described below

commit 1d3d8f533fb744d88bf7621b256c9a7b208266b0
Author: Adam Postek <[email protected]>
AuthorDate: Mon Nov 2 11:11:30 2026 +0000

    Catch lazy timetable errors when starting queued runs (#76288)
    
    Signed-off-by: Adam <[email protected]>
---
 .../src/airflow/jobs/scheduler_job_runner.py       |  2 +
 airflow-core/tests/unit/jobs/test_scheduler_job.py | 45 ++++++++++++++++++++++
 2 files changed, 47 insertions(+)

diff --git a/airflow-core/src/airflow/jobs/scheduler_job_runner.py 
b/airflow-core/src/airflow/jobs/scheduler_job_runner.py
index bbb978dd1ad..4613266093a 100644
--- a/airflow-core/src/airflow/jobs/scheduler_job_runner.py
+++ b/airflow-core/src/airflow/jobs/scheduler_job_runner.py
@@ -3250,6 +3250,8 @@ class SchedulerJobRunner(BaseJobRunner, LoggingMixin):
             backfill_id = dag_run.backfill_id
             try:
                 dag = dag_run.dag = cached_get_dag(dag_run)
+                if dag:
+                    _ = dag.timetable
             except DBAPIError:
                 raise
             except Exception:
diff --git a/airflow-core/tests/unit/jobs/test_scheduler_job.py 
b/airflow-core/tests/unit/jobs/test_scheduler_job.py
index 1df2086c4a9..a66c99a69ae 100644
--- a/airflow-core/tests/unit/jobs/test_scheduler_job.py
+++ b/airflow-core/tests/unit/jobs/test_scheduler_job.py
@@ -152,6 +152,7 @@ from airflow.sdk import (
 from airflow.sdk.definitions._internal.loop import create_loop
 from airflow.sdk.definitions.callback import AsyncCallback, SyncCallback
 from airflow.sdk.definitions.timetables.assets import AssetTriggeredTimetable, 
PartitionedAssetTimetable
+from airflow.serialization.definitions import dag as serialized_dag_definitions
 from airflow.serialization.definitions.dag import SerializedDAG
 from airflow.serialization.encoders import ensure_serialized_asset
 from airflow.serialization.serialized_objects import LazyDeserializedDAG
@@ -8000,6 +8001,50 @@ class TestSchedulerJob:
         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_start_queued_dagruns_does_not_crash_on_lazy_timetable_error(self, 
dag_maker, caplog, session):
+        """The timetable is decoded lazily, so a failing one must be caught 
when the Dag is loaded."""
+        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
+        broken_timetable = {"__type": "broken"}
+        decode_timetable = serialized_dag_definitions.decode_timetable
+
+        def break_bad_dag_timetable(dag_run, session):
+            dag = get_dag_for_run(dag_run=dag_run, session=session)
+            if dag_run.dag_id == "bad_dag":
+                dag._encoded_timetable = broken_timetable
+            return dag
+
+        def fail_for_broken_timetable(encoded):
+            if encoded is broken_timetable:
+                raise RuntimeError("Variable not found")
+            return decode_timetable(encoded)
+
+        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=break_bad_dag_timetable
+            ),
+            patch.object(
+                serialized_dag_definitions, "decode_timetable", 
side_effect=fail_for_broken_timetable
+            ),
+        ):
+            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