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

kaxil 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 098d0174731 Report a Dag whose timetable fails to load as an import 
error (#74709)
098d0174731 is described below

commit 098d0174731a64f59f929d53bf1f8c8ca87601ca
Author: Adam Postek <[email protected]>
AuthorDate: Thu Oct 15 15:42:47 2026 +0000

    Report a Dag whose timetable fails to load as an import error (#74709)
---
 airflow-core/newsfragments/74709.bugfix.rst        |  1 +
 .../src/airflow/dag_processing/collection.py       | 27 +++++++++++++
 .../tests/unit/dag_processing/test_collection.py   | 44 ++++++++++++++++++++++
 3 files changed, 72 insertions(+)

diff --git a/airflow-core/newsfragments/74709.bugfix.rst 
b/airflow-core/newsfragments/74709.bugfix.rst
new file mode 100644
index 00000000000..b8deba9e0d2
--- /dev/null
+++ b/airflow-core/newsfragments/74709.bugfix.rst
@@ -0,0 +1 @@
+Report a Dag whose timetable fails to load, for example because a custom 
timetable reads a missing Variable, as an import error in the UI. Previously 
the error stopped every Dag in the same file from being saved and was only 
visible in the Dag processor log.
diff --git a/airflow-core/src/airflow/dag_processing/collection.py 
b/airflow-core/src/airflow/dag_processing/collection.py
index cd7228d210f..70451890050 100644
--- a/airflow-core/src/airflow/dag_processing/collection.py
+++ b/airflow-core/src/airflow/dag_processing/collection.py
@@ -573,6 +573,32 @@ def _reject_other_teams_plugin_classes(
     return accepted
 
 
+def _reject_dags_with_unloadable_timetables(
+    bundle_name: str,
+    dags: Collection[LazyDeserializedDAG],
+    import_errors: dict[tuple[str, str], str],
+) -> list[LazyDeserializedDAG]:
+    """
+    Drop Dags whose timetable raises when rebuilt, recording an import error 
for each.
+
+    A custom timetable runs user code when it is deserialized, for example 
``Variable.get`` in
+    ``__init__``. Checking here keeps one failing Dag from aborting the save 
of every Dag in its file.
+    """
+    accepted: list[LazyDeserializedDAG] = []
+    for dag in dags:
+        try:
+            dag.timetable
+        except Exception:
+            log.exception("Failed to load timetable of Dag", dag_id=dag.dag_id)
+            depth = conf.getint("core", "dagbag_import_error_traceback_depth")
+            import_errors[(bundle_name, dag.relative_fileloc)] = (
+                f"Failed to load the timetable of Dag 
'{dag.dag_id}':\n{traceback.format_exc(limit=-depth)}"
+            )
+            continue
+        accepted.append(dag)
+    return accepted
+
+
 def update_dag_parsing_results_in_db(
     bundle_name: str,
     bundle_version: str | None,
@@ -616,6 +642,7 @@ def update_dag_parsing_results_in_db(
         without an entry have their source read from ``fileloc``.
     """
     accepted = _reject_other_teams_plugin_classes(bundle_name, dags, 
import_errors, session=session)
+    accepted = _reject_dags_with_unloadable_timetables(bundle_name, accepted, 
import_errors)
     if len(accepted) != len(dags):
         # A rejected Dag may have no ``dag`` row yet, and dag_warning has a 
foreign key to it.
         rejected_ids = {dag.dag_id for dag in dags} - {dag.dag_id for dag in 
accepted}
diff --git a/airflow-core/tests/unit/dag_processing/test_collection.py 
b/airflow-core/tests/unit/dag_processing/test_collection.py
index 6e9a61c8756..4eec804683f 100644
--- a/airflow-core/tests/unit/dag_processing/test_collection.py
+++ b/airflow-core/tests/unit/dag_processing/test_collection.py
@@ -84,6 +84,7 @@ from airflow.sdk import (
 from airflow.sdk.definitions.deadline import AsyncCallback, 
BaseDeadlineReference, DeadlineAlert
 from airflow.sdk.definitions.timetables.assets import AssetOrTimeSchedule, 
PartitionedAssetTimetable
 from airflow.sdk.importers import DagSourceCode
+from airflow.serialization.decoders import decode_timetable
 from airflow.serialization.definitions.assets import SerializedAsset
 from airflow.serialization.encoders import encode_trigger, 
ensure_serialized_asset
 from airflow.serialization.serialized_objects import LazyDeserializedDAG
@@ -1094,6 +1095,49 @@ class TestUpdateDagParsingResults:
         assert len(dag_import_error_listener.existing) == 0
         assert dag_import_error_listener.new["abc.py"] == 
import_error.stacktrace
 
+    @patch.object(ParseImportError, "full_file_path")
+    @pytest.mark.usefixtures("clean_db")
+    def test_timetable_that_fails_to_load_is_an_import_error(
+        self, mock_full_path, session, testing_dag_bundle
+    ):
+        """
+        A timetable that raises when rebuilt (for example a custom timetable 
reading a missing Variable
+        in ``__init__``) is reported as an import error for its file, and only 
that Dag is skipped.
+        """
+        mock_full_path.return_value = "abc.py"
+        dags = []
+        for dag_id, cron in (("good_dag", "0 0 * * *"), ("bad_dag", "0 1 * * 
*")):
+            dag = DAG(dag_id=dag_id, schedule=CronTriggerTimetable(cron, 
timezone="UTC"))
+            dag.fileloc = dag.relative_fileloc = "abc.py"
+            dags.append(LazyDeserializedDAG.from_dag(dag))
+
+        def fail_for_bad_dag(var):
+            if var["__var"]["expression"] == "0 1 * * *":
+                raise RuntimeError("Variable something not found")
+            return decode_timetable(var)
+
+        import_errors: dict[tuple[str, str], str] = {}
+        with 
patch("airflow.serialization.serialized_objects.decode_timetable", 
side_effect=fail_for_bad_dag):
+            update_dag_parsing_results_in_db(
+                "testing",
+                None,
+                dags,
+                import_errors,
+                None,
+                set(),
+                session,
+                files_parsed={("testing", "abc.py")},
+            )
+
+        assert session.get(DagModel, ("good_dag",)) is not None
+        assert session.get(DagModel, ("bad_dag",)) is None
+        error = import_errors[("testing", "abc.py")]
+        assert "bad_dag" in error
+        assert "Traceback (most recent call last)" in error
+        assert "RuntimeError: Variable something not found" in error
+        [stored] = session.scalars(select(ParseImportError)).all()
+        assert stored.filename == "abc.py"
+
     @patch.object(ParseImportError, "full_file_path")
     @mark_fab_auth_manager_test
     @conf_vars({("core", "min_serialized_dag_update_interval"): "5"})

Reply via email to