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

vatsrahul1001 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 e3f5e6ff3af Skip scheduling tasks when their Dag cannot be 
deserialized (#75263)
e3f5e6ff3af is described below

commit e3f5e6ff3affa813834fc3c5048eeff6e00688dd
Author: abhijeets25012-tech <[email protected]>
AuthorDate: Tue Oct 27 11:57:08 2026 +0530

    Skip scheduling tasks when their Dag cannot be deserialized (#75263)
    
    * Skip scheduling tasks when their Dag cannot be deserialized
    
    Keep one Dag's deserialization failure from stopping scheduler progress for 
unrelated Dags.
    
    * Fix scheduler crash on DAG deserialization errors
    
    Isolate deserialization failures to the affected DAG run instead of
    starving the whole DAG, and let DB errors propagate so the scheduler
    does not silently skip task instances.
    
    ---------
    
    Co-authored-by: Abhijeet Raj Singh 
<[email protected]>
---
 .../src/airflow/jobs/scheduler_job_runner.py       |  20 +++-
 airflow-core/tests/unit/jobs/test_scheduler_job.py | 129 +++++++++++++++++++++
 2 files changed, 145 insertions(+), 4 deletions(-)

diff --git a/airflow-core/src/airflow/jobs/scheduler_job_runner.py 
b/airflow-core/src/airflow/jobs/scheduler_job_runner.py
index 01c80d53379..a8ccf1d8145 100644
--- a/airflow-core/src/airflow/jobs/scheduler_job_runner.py
+++ b/airflow-core/src/airflow/jobs/scheduler_job_runner.py
@@ -594,6 +594,7 @@ class SchedulerJobRunner(BaseJobRunner, LoggingMixin):
         task_instance: TI,
         concurrency_map: ConcurrencyMap,
         session: Session,
+        starved_dag_runs: set[tuple[str, str]],
         starved_tasks: set[tuple[str, str]],
         starved_tasks_task_dagrun_concurrency: set[tuple[str, str, str]],
     ) -> bool:
@@ -602,10 +603,20 @@ class SchedulerJobRunner(BaseJobRunner, LoggingMixin):
         task_id = task_instance.task_id
         run_id = task_instance.run_id
 
-        serialized_dag = self.scheduler_dag_bag.get_dag_for_run(
-            dag_run=task_instance.dag_run,
-            session=session,
-        )
+        if (dag_id, run_id) in starved_dag_runs:
+            return False
+
+        try:
+            serialized_dag = self.scheduler_dag_bag.get_dag_for_run(
+                dag_run=task_instance.dag_run,
+                session=session,
+            )
+        except DBAPIError:
+            raise
+        except Exception:
+            self.log.exception("Failed to load Dag %s for run %s, skipping 
it", dag_id, run_id)
+            starved_dag_runs.add((dag_id, run_id))
+            return False
 
         # If the DAG is missing, fail all scheduled TIs for this DAG.
         if not serialized_dag:
@@ -924,6 +935,7 @@ class SchedulerJobRunner(BaseJobRunner, LoggingMixin):
                         task_instance=task_instance,
                         concurrency_map=concurrency_map,
                         session=session,
+                        starved_dag_runs=starved_dag_runs,
                         starved_tasks=starved_tasks,
                         
starved_tasks_task_dagrun_concurrency=(starved_tasks_task_dagrun_concurrency),
                     )
diff --git a/airflow-core/tests/unit/jobs/test_scheduler_job.py 
b/airflow-core/tests/unit/jobs/test_scheduler_job.py
index aa2a9c7c6d3..5ebbb8be9dc 100644
--- a/airflow-core/tests/unit/jobs/test_scheduler_job.py
+++ b/airflow-core/tests/unit/jobs/test_scheduler_job.py
@@ -3632,6 +3632,135 @@ class TestSchedulerJob:
 
         session.rollback()
 
+    def 
test_find_executable_task_instances_skips_dag_that_cannot_be_deserialized(
+        self, dag_maker, caplog, session
+    ):
+        bad_dag_id = (
+            
"SchedulerJobTest.test_find_executable_task_instances_skips_dag_that_cannot_be_deserialized-bad"
+        )
+        good_dag_id = (
+            
"SchedulerJobTest.test_find_executable_task_instances_skips_dag_that_cannot_be_deserialized-good"
+        )
+
+        with dag_maker(dag_id=bad_dag_id):
+            bad_task = EmptyOperator(task_id="bad_task", priority_weight=2, 
max_active_tis_per_dag=1)
+        bad_run_1 = dag_maker.create_dagrun(run_type=DagRunType.SCHEDULED)
+        bad_run_2 = dag_maker.create_dagrun_after(bad_run_1, 
run_type=DagRunType.SCHEDULED)
+
+        with dag_maker(dag_id=good_dag_id):
+            good_task = EmptyOperator(task_id="good_task", priority_weight=1)
+        good_run = dag_maker.create_dagrun(run_type=DagRunType.SCHEDULED)
+
+        bad_running_ti = bad_run_1.get_task_instance(bad_task.task_id, 
session=session)
+        bad_scheduled_ti = bad_run_2.get_task_instance(bad_task.task_id, 
session=session)
+        good_scheduled_ti = good_run.get_task_instance(good_task.task_id, 
session=session)
+        bad_running_ti.state = TaskInstanceState.RUNNING
+        bad_scheduled_ti.state = TaskInstanceState.SCHEDULED
+        good_scheduled_ti.state = TaskInstanceState.SCHEDULED
+        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_id:
+                raise DeserializationError(bad_dag_id) 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),
+        ):
+            result = self.job_runner._select_task_instances_to_queue(
+                1, make_pool_stats(), set(), session=session
+            )
+
+        assert [ti.key for ti in result] == [good_scheduled_ti.key]
+        assert bad_scheduled_ti.state == TaskInstanceState.SCHEDULED
+        assert any(bad_dag_id in record.getMessage() for record in 
caplog.records)
+
+        session.rollback()
+
+    def 
test_find_executable_task_instances_starves_run_not_whole_dag_on_deserialization_error(
+        self, dag_maker, caplog, session
+    ):
+        """A deserialization failure must starve only the affected Dag run.
+
+        Starving ``(dag_id, task_id)`` would also block the same task in 
healthy runs of the
+        same Dag, and every task of the broken run would try to load and log 
the Dag again.
+        """
+        dag_id = (
+            
"SchedulerJobTest.test_find_executable_task_instances_starves_run_not_whole_dag_"
+            "on_deserialization_error"
+        )
+        with dag_maker(dag_id=dag_id):
+            first_task = EmptyOperator(task_id="first_task", 
priority_weight=2, max_active_tis_per_dag=1)
+            second_task = EmptyOperator(task_id="second_task", 
priority_weight=1, max_active_tis_per_dag=1)
+
+        older_run = dag_maker.create_dagrun(run_type=DagRunType.SCHEDULED)
+        newer_run = dag_maker.create_dagrun_after(older_run, 
run_type=DagRunType.SCHEDULED)
+
+        for operator in (first_task, second_task):
+            older_run.get_task_instance(operator.task_id, 
session=session).state = TaskInstanceState.SCHEDULED
+            newer_run.get_task_instance(operator.task_id, 
session=session).state = TaskInstanceState.SCHEDULED
+        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_older_run(dag_run, session):
+            if dag_run.run_id == older_run.run_id:
+                raise DeserializationError(dag_id) 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_older_run
+            ),
+        ):
+            result = self.job_runner._select_task_instances_to_queue(
+                1, make_pool_stats(), set(), session=session
+            )
+
+        assert [ti.run_id for ti in result] == [newer_run.run_id]
+        selected_task_id = result[0].task_id
+        session.expire_all()
+        assert (
+            newer_run.get_task_instance(selected_task_id, 
session=session).state == TaskInstanceState.QUEUED
+        )
+        assert (
+            older_run.get_task_instance(first_task.task_id, 
session=session).state
+            == TaskInstanceState.SCHEDULED
+        )
+        # The failure is logged once for the starved run, not once per task in 
that run.
+        assert sum(dag_id in record.getMessage() for record in caplog.records) 
== 1
+
+        session.rollback()
+
+    def 
test_find_executable_task_instances_reraises_db_errors_from_dag_load(self, 
dag_maker, session):
+        """DBAPIError while loading a Dag must propagate for transaction retry 
handling."""
+        from sqlalchemy.exc import DBAPIError
+
+        with dag_maker(dag_id="db_error_dag_load"):
+            EmptyOperator(task_id="task", max_active_tis_per_dag=1)
+        run = dag_maker.create_dagrun(state=DagRunState.RUNNING)
+        run.get_task_instance("task", session=session).state = 
TaskInstanceState.SCHEDULED
+        session.flush()
+
+        self.job_runner = SchedulerJobRunner(job=Job(), 
executors=[self.null_exec])
+        with patch.object(
+            self.job_runner.scheduler_dag_bag,
+            "get_dag_for_run",
+            side_effect=DBAPIError("select 1", None, Exception("connection 
lost")),
+        ):
+            with pytest.raises(DBAPIError):
+                self.job_runner._select_task_instances_to_queue(1, 
make_pool_stats(), set(), session=session)
+
+        session.rollback()
+
     @pytest.fixture
     def interleaved_blocked_and_runnable_tis(self, dag_maker, session):
         """

Reply via email to