This is an automated email from the ASF dual-hosted git repository.
ashb pushed a commit to branch v3-4-test
in repository https://gitbox.apache.org/repos/asf/airflow.git
The following commit(s) were added to refs/heads/v3-4-test by this push:
new d7e00b5084f [v3-4-test] Report a Dag whose timetable fails to load as
an import error (#74709) (#74807)
d7e00b5084f is described below
commit d7e00b5084f9df89a2a68fa21bcbb6d2ae9c5700
Author: github-actions[bot]
<41898282+github-actions[bot]@users.noreply.github.com>
AuthorDate: Fri Oct 16 17:12:57 2026 +0100
[v3-4-test] Report a Dag whose timetable fails to load as an import error
(#74709) (#74807)
(cherry picked from commit 098d0174731a64f59f929d53bf1f8c8ca87601ca)
Co-authored-by: Adam Postek <[email protected]>
---
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"})