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

ferruzzi 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 f792bd60a98 Do not abort Dag run creation when a deadline alert fails 
(#68917)
f792bd60a98 is described below

commit f792bd60a9852dd91f1281f825e8c92e28f386de
Author: Sean Ghaeli <[email protected]>
AuthorDate: Tue Oct 13 12:53:21 2026 -0700

    Do not abort Dag run creation when a deadline alert fails (#68917)
    
    * Do not abort Dag run creation when a deadline alert fails
    
    Deadline alerts are an observability feature layered on top of a Dag run, so
    they must never be able to stop one from existing. Decoding a stored alert,
    resolving its interval and evaluating its reference all happen inside
    create_dagrun, and any of them could raise straight out through Dag run
    creation -- so one malformed or unresolvable alert took the whole Dag run 
down
    with it, and on a Dag with several alerts the first failure starved the 
rest of
    their deadlines.
    
    The new deadline_alerts.deadline_creation_failed counter keeps these skips
    visible rather than silent.
---
 airflow-core/newsfragments/68917.bugfix.rst        |   1 +
 .../src/airflow/serialization/definitions/dag.py   | 122 ++++++++++------
 .../airflow/serialization/definitions/deadline.py  |   9 +-
 airflow-core/src/airflow/utils/sqlalchemy.py       |   5 +-
 airflow-core/tests/unit/models/test_dagrun.py      | 158 ++++++++++++++++++---
 .../serialization/definitions/test_deadline.py     |   1 +
 .../observability/metrics/metrics_template.yaml    |   6 +
 7 files changed, 237 insertions(+), 65 deletions(-)

diff --git a/airflow-core/newsfragments/68917.bugfix.rst 
b/airflow-core/newsfragments/68917.bugfix.rst
new file mode 100644
index 00000000000..d966d7ba5b9
--- /dev/null
+++ b/airflow-core/newsfragments/68917.bugfix.rst
@@ -0,0 +1 @@
+A deadline alert that cannot be decoded, or whose interval or reference fails 
to resolve, no longer aborts creation of the Dag run it belongs to. The failing 
alert is logged and skipped, the remaining alerts still get their deadlines, 
and a new ``deadline_alerts.deadline_creation_failed`` counter is emitted for 
it.
diff --git a/airflow-core/src/airflow/serialization/definitions/dag.py 
b/airflow-core/src/airflow/serialization/definitions/dag.py
index f95692aad50..c529bf95ade 100644
--- a/airflow-core/src/airflow/serialization/definitions/dag.py
+++ b/airflow-core/src/airflow/serialization/definitions/dag.py
@@ -50,12 +50,13 @@ from airflow.models.deadline import Deadline
 from airflow.models.deadline_alert import DeadlineAlert as DeadlineAlertModel
 from airflow.models.task_coordinates import public_map_index_expression
 from airflow.models.tasklog import LogTemplate
-from airflow.serialization.decoders import decode_deadline_alert_model, 
resolve_deadline_alert_interval
-from airflow.serialization.definitions.deadline import 
SerializedReferenceModels
+from airflow.serialization.decoders import decode_deadline_alert, 
resolve_deadline_alert_interval
+from airflow.serialization.definitions.deadline import DeadlineAlertFields, 
SerializedReferenceModels
 from airflow.serialization.definitions.param import SerializedParamsDict
 from airflow.timetables.base import DagRunInfo, DataInterval, TimeRestriction
 from airflow.utils.helpers import prune_dict
 from airflow.utils.session import NEW_SESSION, provide_session
+from airflow.utils.sqlalchemy import PROHIBIT_COMMIT_ERROR_MESSAGE
 from airflow.utils.state import DagRunState, TaskInstanceState
 from airflow.utils.types import DagRunType
 
@@ -754,54 +755,87 @@ class SerializedDAG:
             
select(DeadlineAlertModel).where(DeadlineAlertModel.serialized_dag_id == 
serialized_dag_id)
         ).all()
 
-        for deadline_alert in deadline_alert_records:
-            if not deadline_alert:
-                continue
-
-            deserialized_deadline_alert = 
decode_deadline_alert_model(deadline_alert)
-            interval = 
resolve_deadline_alert_interval(deserialized_deadline_alert, session=session)
+        if not deadline_alert_records:
+            return
 
-            if isinstance(deserialized_deadline_alert.reference, 
SerializedReferenceModels.TYPES.DAGRUN):
-                deadline_time = 
deserialized_deadline_alert.reference.evaluate_with(
-                    session=session,
-                    interval=interval,
-                    # TODO : Pretty sure we can drop these last two; verify 
after testing is complete
-                    dag_id=self.dag_id,
-                    run_id=orm_dagrun.run_id,
+        team_name = (
+            DagModel.get_team_name(self.dag_id, session=session)
+            if airflow_conf.getboolean("core", "multi_team")
+            else None
+        )
+        metrics_tags = prune_dict({"dag_id": self.dag_id, "team_name": 
team_name})
+
+        # Read every value off the ORM up front. A failing alert can leave the 
session rolled back
+        # and its instances detached, so nothing after this point may touch 
ORM state -- otherwise
+        # the skip path raises DetachedInstanceError and takes the DagRun down 
anyway.
+        run_id = orm_dagrun.run_id
+        dagrun_id = orm_dagrun.id
+        dagrun_dag_id = orm_dagrun.dag_id
+        bundle_name = orm_dagrun.dag_model.bundle_name
+        stored_alerts = [
+            (alert.id, alert.name, alert.reference, alert.interval, 
alert.callback_def)
+            for alert in deadline_alert_records
+            if alert
+        ]
+
+        for alert_id, alert_name, alert_reference, alert_interval, 
alert_callback_def in stored_alerts:
+            # Deadline creation is best-effort. Catch per alert so one bad 
alert cannot starve the rest or abort the DagRun.
+            try:
+                deserialized_deadline_alert = decode_deadline_alert(
+                    {
+                        DeadlineAlertFields.NAME: alert_name,
+                        DeadlineAlertFields.REFERENCE: alert_reference,
+                        DeadlineAlertFields.INTERVAL: alert_interval,
+                        DeadlineAlertFields.CALLBACK: alert_callback_def,
+                    }
                 )
+                interval = 
resolve_deadline_alert_interval(deserialized_deadline_alert, session=session)
 
-                if deadline_time is not None:
-                    session.add(
-                        Deadline(
-                            deadline_time=deadline_time,
-                            callback=deserialized_deadline_alert.callback,
-                            dagrun_id=orm_dagrun.id,
-                            deadline_alert_id=deadline_alert.id,
-                            dag_id=orm_dagrun.dag_id,
-                            bundle_name=orm_dagrun.dag_model.bundle_name,
-                        )
-                    )
-                    team_name = (
-                        DagModel.get_team_name(self.dag_id, session=session)
-                        if airflow_conf.getboolean("core", "multi_team")
-                        else None
-                    )
-                    stats.incr(
-                        "deadline_alerts.deadline_created",
-                        tags=prune_dict({"dag_id": self.dag_id, "team_name": 
team_name}),
-                    )
-                elif required_dagrun_column := 
_DAGRUN_REFERENCE_REQUIRED_COLUMNS.get(
-                    type(deserialized_deadline_alert.reference)
-                ):
-                    log.warning(
-                        "skipping deadline alert because the deadline 
reference evaluated to None",
+                if isinstance(deserialized_deadline_alert.reference, 
SerializedReferenceModels.TYPES.DAGRUN):
+                    deadline_time = 
deserialized_deadline_alert.reference.evaluate_with(
+                        session=session,
+                        interval=interval,
+                        # TODO : Pretty sure we can drop these last two; 
verify after testing is complete
                         dag_id=self.dag_id,
-                        run_id=orm_dagrun.run_id,
-                        deadline_alert_id=deadline_alert.id,
-                        
reference_type=deserialized_deadline_alert.reference.reference_name,
-                        required_dagrun_column=required_dagrun_column,
+                        run_id=run_id,
                     )
 
+                    if deadline_time is not None:
+                        session.add(
+                            Deadline(
+                                deadline_time=deadline_time,
+                                callback=deserialized_deadline_alert.callback,
+                                dagrun_id=dagrun_id,
+                                deadline_alert_id=alert_id,
+                                dag_id=dagrun_dag_id,
+                                bundle_name=bundle_name,
+                            )
+                        )
+                        stats.incr("deadline_alerts.deadline_created", 
tags=metrics_tags)
+                    elif required_dagrun_column := 
_DAGRUN_REFERENCE_REQUIRED_COLUMNS.get(
+                        type(deserialized_deadline_alert.reference)
+                    ):
+                        log.warning(
+                            "skipping deadline alert because the deadline 
reference evaluated to None",
+                            dag_id=self.dag_id,
+                            run_id=run_id,
+                            deadline_alert_id=alert_id,
+                            
reference_type=deserialized_deadline_alert.reference.reference_name,
+                            required_dagrun_column=required_dagrun_column,
+                        )
+            except Exception as exc:
+                if isinstance(exc, RuntimeError) and 
PROHIBIT_COMMIT_ERROR_MESSAGE in str(exc):
+                    # A rejected commit means the scheduler's HA locking is 
already broken. Recording
+                    # that as a skipped alert would hide it, so let it escape 
instead.
+                    raise
+                log.exception(
+                    "skipping deadline alert because creating its deadline 
failed",
+                    dag_id=self.dag_id,
+                    run_id=run_id,
+                    deadline_alert_id=alert_id,
+                )
+                stats.incr("deadline_alerts.deadline_creation_failed", 
tags=metrics_tags)
+
     @provide_session
     def set_task_instance_state(
         self,
diff --git a/airflow-core/src/airflow/serialization/definitions/deadline.py 
b/airflow-core/src/airflow/serialization/definitions/deadline.py
index e4362338cc0..25399aabc62 100644
--- a/airflow-core/src/airflow/serialization/definitions/deadline.py
+++ b/airflow-core/src/airflow/serialization/definitions/deadline.py
@@ -406,7 +406,14 @@ class SerializedVariableInterval:
                 f"VariableInterval '{self.key}' must be an integer (seconds), 
got: {value!r}"
             ) from e
 
-        return timedelta(seconds=seconds)
+        try:
+            return timedelta(seconds=seconds)
+        except OverflowError as e:
+            # ``timedelta`` raises OverflowError, which is not a ValueError, 
so callers
+            # inspecting for a bad interval would otherwise miss it.
+            raise ValueError(
+                f"VariableInterval '{self.key}' is too large to be a valid 
interval: {seconds} seconds"
+            ) from e
 
 
 @attrs.define
diff --git a/airflow-core/src/airflow/utils/sqlalchemy.py 
b/airflow-core/src/airflow/utils/sqlalchemy.py
index 6b7db82b8ef..5d90ef63ec0 100644
--- a/airflow-core/src/airflow/utils/sqlalchemy.py
+++ b/airflow-core/src/airflow/utils/sqlalchemy.py
@@ -682,6 +682,9 @@ def with_db_lock_timeout(session: Session, lock_timeout: 
int = 30) -> Generator[
             session.execute(text(f"SET SESSION innodb_lock_wait_timeout = 
{old_mysql_timeout}"))
 
 
+PROHIBIT_COMMIT_ERROR_MESSAGE = "UNEXPECTED COMMIT - THIS WILL BREAK HA LOCKS!"
+
+
 class CommitProhibitorGuard:
     """Context manager class that powers prohibit_commit."""
 
@@ -694,7 +697,7 @@ class CommitProhibitorGuard:
         if self.expected_commit:
             self.expected_commit = False
             return
-        raise RuntimeError("UNEXPECTED COMMIT - THIS WILL BREAK HA LOCKS!")
+        raise RuntimeError(PROHIBIT_COMMIT_ERROR_MESSAGE)
 
     def __enter__(self) -> Self:
         event.listen(self.session, "before_commit", self._validate_commit)
diff --git a/airflow-core/tests/unit/models/test_dagrun.py 
b/airflow-core/tests/unit/models/test_dagrun.py
index 25795e40d8e..4e00a1392ea 100644
--- a/airflow-core/tests/unit/models/test_dagrun.py
+++ b/airflow-core/tests/unit/models/test_dagrun.py
@@ -21,7 +21,7 @@ import datetime
 from collections import defaultdict
 from collections.abc import Mapping
 from concurrent.futures import ThreadPoolExecutor
-from contextlib import contextmanager
+from contextlib import contextmanager, nullcontext
 from functools import partial, reduce
 from typing import TYPE_CHECKING
 from unittest import mock
@@ -1954,30 +1954,150 @@ class TestDagRun:
             "log_level": "warning",
         } not in caplog
 
+    @pytest.mark.parametrize(
+        ("interval", "failure"),
+        [
+            pytest.param(
+                VariableInterval("missing_key"),
+                mock.patch.object(Variable, "get", side_effect=KeyError),
+                id="unresolvable_interval",
+            ),
+            pytest.param(
+                datetime.timedelta(hours=1),
+                mock.patch(
+                    
"airflow.serialization.definitions.dag.decode_deadline_alert",
+                    autospec=True,
+                    side_effect=ValueError("corrupt deadline alert blob"),
+                ),
+                id="undecodable_alert",
+            ),
+            pytest.param(
+                datetime.timedelta(hours=1),
+                mock.patch.object(
+                    SerializedReferenceModels.FixedDatetimeDeadline,
+                    "evaluate_with",
+                    autospec=True,
+                    side_effect=RuntimeError("evaluate_with failed"),
+                ),
+                id="unevaluable_reference",
+            ),
+        ],
+    )
+    @mock.patch("airflow._shared.observability.metrics.stats.incr")
     @mock.patch.object(Deadline, "prune_deadlines")
-    def test_dagrun_deadline_variable_interval_missing_variable_fails(self, _, 
session, deadline_test_dag):
+    def test_dagrun_deadline_failure_does_not_abort_dagrun(
+        self, _, mock_stats_incr, interval, failure, session, deadline_test_dag
+    ):
+        future_date = datetime.datetime(2037, 1, 1, tzinfo=datetime.UTC)
 
-        with mock.patch.object(
-            Variable,
-            "get",
-            side_effect=KeyError,
+        scheduler_dag = deadline_test_dag(
+            deadline=DeadlineAlert(
+                reference=DeadlineReference.FIXED_DATETIME(future_date),
+                interval=interval,
+                callback=AsyncCallback(empty_callback_for_deadline),
+            ),
+        )
+
+        with (
+            conf_vars({("core", "multi_team"): "true"}),
+            mock.patch("airflow.models.dag.DagModel.get_team_name", 
return_value="team_alpha"),
+            failure,
         ):
-            future_date = datetime.datetime.now() + 
datetime.timedelta(days=365)
+            dag_run = self.create_dag_run(
+                dag=scheduler_dag,
+                task_states={"task_1": TaskInstanceState.SUCCESS},
+                session=session,
+            )
+
+        assert dag_run is not None
+        assert session.execute(select(Deadline)).scalars().one_or_none() is 
None
+        mock_stats_incr.assert_any_call(
+            "deadline_alerts.deadline_creation_failed",
+            tags={"dag_id": scheduler_dag.dag_id, "team_name": "team_alpha"},
+        )
 
-            scheduler_dag = deadline_test_dag(
-                deadline=DeadlineAlert(
-                    reference=DeadlineReference.FIXED_DATETIME(future_date),
-                    interval=VariableInterval("missing_key"),
-                    callback=AsyncCallback(empty_callback_for_deadline),
+    @mock.patch("airflow._shared.observability.metrics.stats.incr")
+    @mock.patch.object(Deadline, "prune_deadlines")
+    @mock.patch.object(Variable, "get")
+    def test_dagrun_deadline_failure_that_detaches_orm_objects_still_skips(
+        self, mock_variable_get, _, mock_stats_incr, session, deadline_test_dag
+    ):
+        """A failing interval resolution can roll back and close the caller's 
session underneath
+        the handler (``create_session`` reusing the scoped session does 
exactly that), detaching
+        every ORM instance. The skip path must still log and count without 
touching ORM state."""
+        scheduler_dag = deadline_test_dag(
+            deadline=DeadlineAlert(
+                reference=DeadlineReference.FIXED_DATETIME(
+                    datetime.datetime(2037, 1, 1, tzinfo=datetime.UTC)
                 ),
-            )
+                interval=VariableInterval("missing_key"),
+                callback=AsyncCallback(empty_callback_for_deadline),
+            ),
+        )
+        # The alert rows must be persistent from an earlier transaction (as in 
the live
+        # scheduler, where the dag processor committed them) so the rollback 
below expires
+        # them and the close detaches them instead of returning them to 
transient.
+        session.commit()
 
-            with pytest.raises(ValueError, match="not found"):
-                self.create_dag_run(
-                    dag=scheduler_dag,
-                    task_states={"task_1": TaskInstanceState.SUCCESS},
-                    session=session,
-                )
+        def teardown_session_and_raise(*args, **kwargs):
+            session.rollback()
+            session.close()
+            raise KeyError("missing_key")
+
+        mock_variable_get.side_effect = teardown_session_and_raise
+
+        dag_run = self.create_dag_run(dag=scheduler_dag, session=session)
+
+        assert dag_run is not None
+        mock_stats_incr.assert_any_call(
+            "deadline_alerts.deadline_creation_failed",
+            tags={"dag_id": scheduler_dag.dag_id},
+        )
+
+    @pytest.mark.parametrize(
+        ("interval", "expect_deadline", "variable_value"),
+        [
+            pytest.param(datetime.timedelta(seconds=5), True, None, 
id="deadline_created"),
+            pytest.param(VariableInterval("missing_key"), False, None, 
id="deadline_skipped"),
+            pytest.param(VariableInterval("resolvable_key"), True, "4200", 
id="variable_interval_resolved"),
+        ],
+    )
+    @mock.patch.object(Deadline, "prune_deadlines")
+    def test_dagrun_deadline_handling_does_not_commit(
+        self, _, interval, expect_deadline, variable_value, session, 
deadline_test_dag
+    ):
+        """DagRuns are created under ``prohibit_commit``, so creating a 
deadline, skipping one whose
+        interval will not resolve, and resolving a ``VariableInterval`` out of 
the metadata database
+        all have to leave the scheduler's session uncommitted."""
+        future_date = datetime.datetime(2037, 1, 1, tzinfo=datetime.UTC)
+        scheduler_dag = deadline_test_dag(
+            deadline=DeadlineAlert(
+                reference=DeadlineReference.FIXED_DATETIME(future_date),
+                interval=interval,
+                callback=AsyncCallback(empty_callback_for_deadline),
+            ),
+        )
+
+        if variable_value is None:
+            variable_lookup = mock.patch.object(Variable, "get", 
side_effect=KeyError)
+        else:
+            # A real row committed before the guard opens, so resolve() has to 
go through
+            # MetastoreBackend. Drop the session forwarding and the backend 
opens the same scoped
+            # session and commits it, which the guard rejects and the lookup 
then reports as missing.
+            Variable.set(key=interval.key, value=variable_value, 
session=session)
+            session.commit()
+            variable_lookup = nullcontext()
+
+        # No task_states: the helper's get_task_instance() opens its own 
session and would trip the guard.
+        with prohibit_commit(session), variable_lookup:
+            dag_run = self.create_dag_run(dag=scheduler_dag, session=session)
+        session.flush()
+
+        assert dag_run is not None
+        deadline = session.execute(select(Deadline)).scalars().one_or_none()
+        assert (deadline is not None) is expect_deadline
+        if variable_value is not None:
+            assert deadline.deadline_time == future_date + 
datetime.timedelta(seconds=int(variable_value))
 
 
 @pytest.mark.parametrize(
diff --git a/airflow-core/tests/unit/serialization/definitions/test_deadline.py 
b/airflow-core/tests/unit/serialization/definitions/test_deadline.py
index f0fad39a37e..643d03bbb95 100644
--- a/airflow-core/tests/unit/serialization/definitions/test_deadline.py
+++ b/airflow-core/tests/unit/serialization/definitions/test_deadline.py
@@ -61,6 +61,7 @@ class TestVariableInterval:
             (None, True, "not found"),
             ("abc", False, "must be an integer"),
             ("", False, "must be an integer"),
+            ("99999999999999", False, "too large to be a valid interval"),
         ],
     )
     def test_resolve_invalid(self, mocker, value, raise_missing, match):
diff --git 
a/shared/observability/src/airflow_shared/observability/metrics/metrics_template.yaml
 
b/shared/observability/src/airflow_shared/observability/metrics/metrics_template.yaml
index 9aa2b7a4a9d..4782f69b7d7 100644
--- 
a/shared/observability/src/airflow_shared/observability/metrics/metrics_template.yaml
+++ 
b/shared/observability/src/airflow_shared/observability/metrics/metrics_template.yaml
@@ -326,6 +326,12 @@ metrics:
     legacy_name: "-"
     name_variables: []
 
+  - name: "deadline_alerts.deadline_creation_failed"
+    description: "Number of deadline alerts skipped because their deadline 
could not be created"
+    type: "counter"
+    legacy_name: "-"
+    name_variables: []
+
   - name: "deadline_alerts.deadline_missed"
     description: "Number of deadline alerts that fired because a Dag run 
missed its deadline"
     type: "counter"

Reply via email to