This is an automated email from the ASF dual-hosted git repository.
vatsrahul1001 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 d0cd2183cf0 [v3-4-test] Pass Dag runs directly to Deadline references
(#70714) (#74986)
d0cd2183cf0 is described below
commit d0cd2183cf027c703fa760b4dc645749fce09a5f
Author: github-actions[bot]
<41898282+github-actions[bot]@users.noreply.github.com>
AuthorDate: Sun Oct 18 17:30:53 2026 +0530
[v3-4-test] Pass Dag runs directly to Deadline references (#70714) (#74986)
* Fix custom Deadline references with optional keyword arguments
Preserve the Dag run evaluation context for custom references that accept
additional keyword arguments, so they continue to evaluate correctly.
(cherry picked from commit 637b741f1a60f0a059dd60f2a62bd47c0e38cd54)
Co-authored-by: Richard <[email protected]>
Co-authored-by: D. Ferruzzi <[email protected]>
---
airflow-core/docs/howto/deadline-alerts.rst | 25 +-
airflow-core/newsfragments/70714.significant.rst | 1 +
airflow-core/src/airflow/models/deadline.py | 139 ++++-------
airflow-core/src/airflow/models/taskinstance.py | 25 +-
.../src/airflow/serialization/definitions/dag.py | 2 +-
.../airflow/serialization/definitions/deadline.py | 90 ++------
airflow-core/tests/unit/models/test_deadline.py | 257 ++++-----------------
.../tests/unit/models/test_deadline_alert.py | 84 ++++++-
task-sdk/src/airflow/sdk/definitions/deadline.py | 37 ++-
9 files changed, 224 insertions(+), 436 deletions(-)
diff --git a/airflow-core/docs/howto/deadline-alerts.rst
b/airflow-core/docs/howto/deadline-alerts.rst
index 76b18a99e45..538173c3505 100644
--- a/airflow-core/docs/howto/deadline-alerts.rst
+++ b/airflow-core/docs/howto/deadline-alerts.rst
@@ -466,9 +466,9 @@ Place the reference classes and the plugin that registers
them in your plugins f
class MyCustomDecoratedReference(BaseDeadlineReference):
"""A custom reference evaluated when Dag runs are created."""
- def _evaluate_with(self, *, session: Session, **kwargs) -> datetime:
- # Add your business logic here
- return your_datetime
+ def _evaluate_with(self, *, session: Session, dagrun) -> datetime:
+ my_datetime = my_business_logic(dagrun.logical_date)
+ return my_datetime
# You can specify when evaluate_with will be called by providing a
DeadlineReference.TYPES value.
@@ -476,14 +476,9 @@ Place the reference classes and the plugin that registers
them in your plugins f
class MyQueuedReference(BaseDeadlineReference):
"""A custom reference evaluated when Dag runs are queued."""
- # Ask for the Dag run context values supplied by Airflow; see notes
below.
- required_kwargs = {"dag_id", "run_id"}
-
- def _evaluate_with(self, *, session: Session, **kwargs) -> datetime:
- dag_id = kwargs["dag_id"]
- run_id = kwargs["run_id"]
- # Use dag_id and run_id in your calculation
- return your_datetime
+ def _evaluate_with(self, *, session: Session, dagrun) -> datetime:
+ my_datetime = my_business_logic(dagrun.queued_at)
+ return my_datetime
# Register the classes so the scheduler can resolve them when it
deserializes the Dag.
@@ -564,8 +559,8 @@ followed by a more urgent escalation if the Dag is still
running.
* **Plugin Registration**: Custom references must be listed in the
``deadline_references`` attribute
of an ``AirflowPlugin``, so the plugins directory is the natural home for
them.
* **API Server Restart**: Restart the Airflow API Server after adding or
modifying custom references.
-* **Required Parameters**: ``required_kwargs`` declares which Dag run context
values Airflow should
- forward to ``_evaluate_with()``. Only ``dag_id`` and ``run_id`` are
available; declaring anything
- else raises a ``ValueError`` when the deadline is evaluated. To configure a
reference itself, give
- it constructor fields or read from an Airflow Variable.
+* **Dag run context**: Add a ``dagrun`` parameter to ``_evaluate_with()`` to
use the Dag run being evaluated.
+* **Required Parameters** *(deprecated)*: ``required_kwargs`` declares which
parameters your reference
+ needs. It still works but is deprecated and will be removed in Airflow 4.0 —
declare the keyword-only
+ parameters your ``_evaluate_with()`` needs instead.
* **Database Access**: Use the ``session`` parameter for Airflow database
queries if needed.
diff --git a/airflow-core/newsfragments/70714.significant.rst
b/airflow-core/newsfragments/70714.significant.rst
new file mode 100644
index 00000000000..70572b6d598
--- /dev/null
+++ b/airflow-core/newsfragments/70714.significant.rst
@@ -0,0 +1 @@
+Deprecate the ``required_kwargs`` attribute for custom deadline references.
Implement ``_evaluate_with()`` with a ``dagrun`` parameter instead.
diff --git a/airflow-core/src/airflow/models/deadline.py
b/airflow-core/src/airflow/models/deadline.py
index 77e3c682e7b..b9c212dc834 100644
--- a/airflow-core/src/airflow/models/deadline.py
+++ b/airflow-core/src/airflow/models/deadline.py
@@ -17,21 +17,23 @@
from __future__ import annotations
import logging
+import warnings
from abc import ABC, abstractmethod
from collections.abc import Sequence
from dataclasses import dataclass
from datetime import datetime, timedelta
-from typing import TYPE_CHECKING, Any, cast
+from inspect import signature
+from typing import TYPE_CHECKING, Any, Protocol, cast
from uuid import UUID
import uuid6
-from sqlalchemy import Boolean, ForeignKey, Index, Integer, Uuid, and_, func,
inspect, select, text
-from sqlalchemy.exc import SQLAlchemyError
+from sqlalchemy import Boolean, ForeignKey, Index, Integer, Uuid, and_, func,
select, text
from sqlalchemy.orm import Mapped, mapped_column, relationship
from airflow._shared.observability.metrics import stats
from airflow._shared.timezones import timezone
from airflow.configuration import conf
+from airflow.exceptions import RemovedInAirflow4Warning
from airflow.models.base import Base
from airflow.models.callback import (
Callback,
@@ -57,6 +59,38 @@ logger = logging.getLogger(__name__)
CALLBACK_METRICS_PREFIX = "deadline_alerts"
+class DeadlineDagRunProtocol(Protocol):
+ """The Dag run interface deadline references are evaluated against."""
+
+ dag_id: str
+ logical_date: datetime | None
+ queued_at: datetime | None
+
+
+def _get_evaluation_kwargs(reference: Any, evaluator: Any, kwargs: dict[str,
Any]) -> dict[str, Any]:
+ """Return the evaluation arguments accepted by a deadline reference."""
+ required_kwargs: set[str] | None = getattr(reference, "required_kwargs",
None)
+ if required_kwargs is not None:
+ warnings.warn(
+ "required_kwargs is deprecated. Declare the keyword-only
parameters your "
+ "_evaluate_with() implementation needs instead.",
+ RemovedInAirflow4Warning,
+ stacklevel=3,
+ )
+ kwargs = {key: value for key, value in kwargs.items() if key in
required_kwargs}
+ if missing_kwargs := required_kwargs - kwargs.keys():
+ raise ValueError(
+ f"{reference.__class__.__name__} is missing required
parameters: {', '.join(missing_kwargs)}"
+ )
+ return kwargs
+
+ parameters = signature(evaluator).parameters
+ if any(param.kind is param.VAR_KEYWORD for param in parameters.values()):
+ return kwargs
+
+ return {key: value for key, value in kwargs.items() if key in parameters}
+
+
class classproperty:
"""
Decorator that converts a method with a single cls argument into a
property.
@@ -323,34 +357,18 @@ class ReferenceModels:
class BaseDeadlineReference(LoggingMixin, ABC):
"""Base class for all Deadline implementations."""
- # Set of required kwargs - subclasses should override this.
- required_kwargs: set[str] = set()
-
@classproperty
def reference_name(cls: Any) -> str:
return cls.__name__
def evaluate_with(self, *, session: Session, interval: timedelta,
**kwargs: Any) -> datetime | None:
- """Validate the provided kwargs and evaluate this deadline with
the given conditions."""
- filtered_kwargs = {k: v for k, v in kwargs.items() if k in
self.required_kwargs}
-
- if missing_kwargs := self.required_kwargs - filtered_kwargs.keys():
- raise ValueError(
- f"{self.__class__.__name__} is missing required
parameters: {', '.join(missing_kwargs)}"
- )
-
- if extra_kwargs := kwargs.keys() - filtered_kwargs.keys():
- self.log.debug(
- "%s ignoring unexpected parameters: %s",
- self.reference_name,
- ", ".join(extra_kwargs),
- )
-
- base_time = self._evaluate_with(session=session, **filtered_kwargs)
+ """Evaluate this deadline with the supplied context."""
+ evaluation_kwargs = _get_evaluation_kwargs(self,
self._evaluate_with, kwargs)
+ base_time = self._evaluate_with(session=session,
**evaluation_kwargs)
return base_time + interval if base_time is not None else None
@abstractmethod
- def _evaluate_with(self, *, session: Session, **kwargs: Any) ->
datetime | None:
+ def _evaluate_with(self, *, session: Session, dagrun:
DeadlineDagRunProtocol) -> datetime | None:
"""Must be implemented by subclasses to perform the actual
evaluation."""
raise NotImplementedError
@@ -384,7 +402,7 @@ class ReferenceModels:
_datetime: datetime
- def _evaluate_with(self, *, session: Session, **kwargs: Any) ->
datetime | None:
+ def _evaluate_with(self, *, session: Session, **kwargs) -> datetime |
None:
return self._datetime
def serialize_reference(self) -> dict:
@@ -400,23 +418,14 @@ class ReferenceModels:
class DagRunLogicalDateDeadline(BaseDeadlineReference):
"""A deadline that returns a DagRun's logical date."""
- required_kwargs = {"dag_id", "run_id"}
-
- def _evaluate_with(self, *, session: Session, **kwargs: Any) ->
datetime | None:
- from airflow.models import DagRun
-
- return _fetch_from_db(DagRun.logical_date, session=session,
**kwargs)
+ def _evaluate_with(self, *, session: Session, dagrun:
DeadlineDagRunProtocol) -> datetime | None:
+ return dagrun.logical_date
class DagRunQueuedAtDeadline(BaseDeadlineReference):
"""A deadline that returns when a DagRun was queued."""
- required_kwargs = {"dag_id", "run_id"}
-
- @provide_session
- def _evaluate_with(self, *, session: Session, **kwargs: Any) ->
datetime | None:
- from airflow.models import DagRun
-
- return _fetch_from_db(DagRun.queued_at, session=session, **kwargs)
+ def _evaluate_with(self, *, session: Session, dagrun:
DeadlineDagRunProtocol) -> datetime | None:
+ return dagrun.queued_at
@dataclass
class AverageRuntimeDeadline(BaseDeadlineReference):
@@ -425,7 +434,6 @@ class ReferenceModels:
DEFAULT_LIMIT = 10
max_runs: int
min_runs: int | None = None
- required_kwargs = {"dag_id"}
def __post_init__(self):
if self.min_runs is None:
@@ -434,10 +442,10 @@ class ReferenceModels:
raise ValueError("min_runs must be at least 1")
@provide_session
- def _evaluate_with(self, *, session: Session, **kwargs: Any) ->
datetime | None:
+ def _evaluate_with(self, *, session: Session, dagrun:
DeadlineDagRunProtocol) -> datetime | None:
from airflow.models import DagRun
- dag_id = kwargs["dag_id"]
+ dag_id = dagrun.dag_id
# Get database dialect to use appropriate time difference
calculation
dialect = get_dialect_name(session)
@@ -510,54 +518,3 @@ class ReferenceModels:
DeadlineReferenceType = ReferenceModels.BaseDeadlineReference
-
-
-@provide_session
-def _fetch_from_db(model_reference: Mapped, *, session=None, **conditions) ->
datetime | None:
- """
- Fetch a datetime value from the database using the provided model
reference and filtering conditions.
-
- For example, to fetch a TaskInstance's start_date:
- _fetch_from_db(
- TaskInstance.start_date, dag_id='example_dag',
task_id='example_task', run_id='example_run'
- )
-
- This generates SQL equivalent to:
- SELECT start_date
- FROM task_instance
- WHERE dag_id = 'example_dag'
- AND task_id = 'example_task'
- AND run_id = 'example_run'
-
- :param model_reference: SQLAlchemy Column to select (e.g.,
DagRun.logical_date, TaskInstance.start_date)
- :param conditions: Filtering conditions applied as equality comparisons in
the WHERE clause.
- Multiple conditions are combined with AND.
- :param session: SQLAlchemy session (auto-provided by decorator)
- """
- query = select(model_reference)
-
- for key, value in conditions.items():
- inspected = inspect(model_reference)
- if inspected is not None:
- query = query.where(getattr(inspected.class_, key) == value)
-
- compiled_query = query.compile(compile_kwargs={"literal_binds": True})
- pretty_query = "\n ".join(str(compiled_query).splitlines())
- logger.debug(
- "Executing query:\n %r\nAs SQL:\n %s",
- query,
- pretty_query,
- )
-
- try:
- result = session.scalar(query)
- except SQLAlchemyError:
- logger.exception("Database query failed.")
- raise
-
- if result is None:
- message = f"No matching record found in the database for query:\n
{pretty_query}"
- logger.error(message)
- raise ValueError(message)
-
- return result
diff --git a/airflow-core/src/airflow/models/taskinstance.py
b/airflow-core/src/airflow/models/taskinstance.py
index 8c3a811a17b..c90deef1b60 100644
--- a/airflow-core/src/airflow/models/taskinstance.py
+++ b/airflow-core/src/airflow/models/taskinstance.py
@@ -255,14 +255,11 @@ def _add_and_prime_mapped_ti(
set_committed_value(ti, "dag_run", dag_run)
-def _recalculate_dagrun_queued_at_deadlines(
- dagrun: DagRun, new_queued_at: datetime, session: Session
-) -> None:
+def _recalculate_dagrun_queued_at_deadlines(dagrun: DagRun, *, session:
Session) -> None:
"""
Recalculate deadline times for deadlines that reference dagrun.queued_at.
:param dagrun: The DagRun whose deadlines should be recalculated
- :param new_queued_at: The new queued_at timestamp to use for calculation
:param session: Database session
:meta private:
@@ -285,12 +282,9 @@ def _recalculate_dagrun_queued_at_deadlines(
from airflow.serialization.decoders import decode_deadline_alert_model,
resolve_deadline_alert_interval
for deadline, deadline_alert in results:
- # We can't use evaluate_with() since the new queued_at is not written
to the DB yet, and
- # interval is stored as JSON, so it has to be decoded rather than
passed to timedelta().
try:
- interval = resolve_deadline_alert_interval(
- decode_deadline_alert_model(deadline_alert), session=session
- )
+ decoded_alert = decode_deadline_alert_model(deadline_alert)
+ interval = resolve_deadline_alert_interval(decoded_alert,
session=session)
except (ValueError, TypeError):
# Either step can fail: the alert may hold a payload the decoder
refuses, or a
# variable-backed interval may point at a Variable that is missing
or non-numeric.
@@ -304,7 +298,16 @@ def _recalculate_dagrun_queued_at_deadlines(
)
continue
- new_deadline_time = new_queued_at + interval
+ new_deadline_time = decoded_alert.reference.evaluate_with(
+ session=session,
+ interval=interval,
+ dagrun=dagrun,
+ dag_id=dagrun.dag_id,
+ run_id=dagrun.run_id,
+ )
+
+ if new_deadline_time is None:
+ continue
log.debug(
"Recalculating deadline %s for DagRun %s.%s: old=%s, new=%s",
@@ -603,7 +606,7 @@ def clear_task_instances(
parent_context=parent_trace_context(dr.conf),
)
- _recalculate_dagrun_queued_at_deadlines(dr, dr.queued_at,
session)
+ _recalculate_dagrun_queued_at_deadlines(dr, session=session)
if was_finished:
dr.state = dag_run_state
diff --git a/airflow-core/src/airflow/serialization/definitions/dag.py
b/airflow-core/src/airflow/serialization/definitions/dag.py
index c529bf95ade..64ceeff7ccc 100644
--- a/airflow-core/src/airflow/serialization/definitions/dag.py
+++ b/airflow-core/src/airflow/serialization/definitions/dag.py
@@ -795,7 +795,7 @@ class SerializedDAG:
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
+ dagrun=orm_dagrun,
dag_id=self.dag_id,
run_id=run_id,
)
diff --git a/airflow-core/src/airflow/serialization/definitions/deadline.py
b/airflow-core/src/airflow/serialization/definitions/deadline.py
index 25399aabc62..c7f1f1ee111 100644
--- a/airflow-core/src/airflow/serialization/definitions/deadline.py
+++ b/airflow-core/src/airflow/serialization/definitions/deadline.py
@@ -27,7 +27,7 @@ import attrs
from sqlalchemy import select
from airflow._shared.timezones import timezone
-from airflow.models.deadline import classproperty
+from airflow.models.deadline import _get_evaluation_kwargs, classproperty
from airflow.models.variable import Variable
from airflow.utils.log.logging_mixin import LoggingMixin
from airflow.utils.session import provide_session
@@ -39,6 +39,8 @@ if TYPE_CHECKING:
from sqlalchemy import ColumnElement
from sqlalchemy.orm import Session
+ from airflow.models.deadline import DeadlineDagRunProtocol
+
logger = logging.getLogger(__name__)
@@ -95,33 +97,18 @@ class SerializedReferenceModels:
class SerializedBaseDeadlineReference(LoggingMixin, ABC):
"""Base class for all serialized Deadline implementations."""
- required_kwargs: set[str] = set()
-
@classproperty
def reference_name(cls: Any) -> str:
return cls.__name__
def evaluate_with(self, *, session: Session, interval: timedelta,
**kwargs: Any) -> datetime | None:
- """Validate the provided kwargs and evaluate this deadline with
the given conditions."""
- filtered_kwargs = {k: v for k, v in kwargs.items() if k in
self.required_kwargs}
-
- if missing_kwargs := self.required_kwargs - filtered_kwargs.keys():
- raise ValueError(
- f"{self.__class__.__name__} is missing required
parameters: {', '.join(missing_kwargs)}"
- )
-
- if extra_kwargs := kwargs.keys() - filtered_kwargs.keys():
- self.log.debug(
- "%s ignoring unexpected parameters: %s",
- self.reference_name,
- ", ".join(extra_kwargs),
- )
-
- base_time = self._evaluate_with(session=session, **filtered_kwargs)
+ """Evaluate this deadline with the supplied context."""
+ evaluation_kwargs = _get_evaluation_kwargs(self,
self._evaluate_with, kwargs)
+ base_time = self._evaluate_with(session=session,
**evaluation_kwargs)
return base_time + interval if base_time is not None else None
@abstractmethod
- def _evaluate_with(self, *, session: Session, **kwargs: Any) ->
datetime | None:
+ def _evaluate_with(self, *, session: Session, dagrun:
DeadlineDagRunProtocol) -> datetime | None:
"""Must be implemented by subclasses to perform the actual
evaluation."""
raise NotImplementedError
@@ -168,23 +155,14 @@ class SerializedReferenceModels:
class DagRunLogicalDateDeadline(SerializedBaseDeadlineReference):
"""A deadline that returns a DagRun's logical date."""
- required_kwargs = {"dag_id", "run_id"}
-
- def _evaluate_with(self, *, session: Session, **kwargs: Any) ->
datetime | None:
- from airflow.models import DagRun
-
- return _fetch_from_db(DagRun.logical_date, session=session,
**kwargs)
+ def _evaluate_with(self, *, session: Session, dagrun:
DeadlineDagRunProtocol) -> datetime | None:
+ return dagrun.logical_date
class DagRunQueuedAtDeadline(SerializedBaseDeadlineReference):
"""A deadline that returns when a DagRun was queued."""
- required_kwargs = {"dag_id", "run_id"}
-
- @provide_session
- def _evaluate_with(self, *, session: Session, **kwargs: Any) ->
datetime | None:
- from airflow.models import DagRun
-
- return _fetch_from_db(DagRun.queued_at, session=session, **kwargs)
+ def _evaluate_with(self, *, session: Session, dagrun:
DeadlineDagRunProtocol) -> datetime | None:
+ return dagrun.queued_at
@dataclass
class AverageRuntimeDeadline(SerializedBaseDeadlineReference):
@@ -193,7 +171,6 @@ class SerializedReferenceModels:
DEFAULT_LIMIT = 10
max_runs: int
min_runs: int | None = None
- required_kwargs = {"dag_id"}
def __post_init__(self):
if self.min_runs is None:
@@ -202,13 +179,13 @@ class SerializedReferenceModels:
raise ValueError("min_runs must be at least 1")
@provide_session
- def _evaluate_with(self, *, session: Session, **kwargs: Any) ->
datetime | None:
- from sqlalchemy import func, select, text
+ def _evaluate_with(self, *, session: Session, dagrun:
DeadlineDagRunProtocol) -> datetime | None:
+ from sqlalchemy import func, text
from airflow.models import DagRun
from airflow.utils.state import DagRunState
- dag_id = kwargs["dag_id"]
+ dag_id = dagrun.dag_id
dialect = get_dialect_name(session)
@@ -279,7 +256,7 @@ class SerializedReferenceModels:
"""
Wrapper for custom deadline references.
- This class dynamically delegates to the wrapped reference for
required_kwargs and evaluation logic.
+ This class dynamically delegates to the wrapped reference's evaluation
logic.
"""
def __init__(self, inner_ref):
@@ -290,23 +267,9 @@ class SerializedReferenceModels:
return self.inner_ref.reference_name
def evaluate_with(self, *, session: Session, interval: timedelta,
**kwargs: Any) -> datetime | None:
- """Validate the provided kwargs and evaluate this deadline with
the given conditions."""
- required_kwargs: set[str] = getattr(self.inner_ref,
"required_kwargs", set())
- filtered_kwargs = {k: v for k, v in kwargs.items() if k in
required_kwargs}
-
- if missing_kwargs := required_kwargs - filtered_kwargs.keys():
- raise ValueError(
- f"{self.inner_ref.__class__.__name__} is missing required
parameters: {', '.join(missing_kwargs)}"
- )
-
- if extra_kwargs := kwargs.keys() - filtered_kwargs.keys():
- self.log.debug(
- "%s ignoring unexpected parameters: %s",
- self.reference_name,
- ", ".join(extra_kwargs),
- )
-
- deadline = self.inner_ref._evaluate_with(session=session,
**filtered_kwargs)
+ """Evaluate the wrapped custom reference with the supplied
context."""
+ evaluation_kwargs = _get_evaluation_kwargs(self.inner_ref,
self.inner_ref._evaluate_with, kwargs)
+ deadline = self.inner_ref._evaluate_with(session=session,
**evaluation_kwargs)
return deadline + interval if deadline is not None else None
def _evaluate_with(self, *, session: Session, **kwargs: Any) ->
datetime | None:
@@ -365,23 +328,6 @@ SerializedReferenceModels.TYPES.DAGRUN = (
)
-def _fetch_from_db(column, *, session: Session, dag_id: str, run_id: str) ->
datetime | None:
- """
- Fetch a datetime column from the DagRun table.
-
- :meta private:
- """
- from airflow.models import DagRun
-
- row = session.execute(
- select(column).where(DagRun.dag_id == dag_id, DagRun.run_id == run_id)
- ).one_or_none()
- if row is None:
- logger.warning("Could not find DagRun for dag_id=%s, run_id=%s",
dag_id, run_id)
- return None
- return row[0]
-
-
@attrs.define(frozen=True)
class SerializedVariableInterval:
"""Core-side serialized representation of a variable-backed deadline
interval."""
diff --git a/airflow-core/tests/unit/models/test_deadline.py
b/airflow-core/tests/unit/models/test_deadline.py
index 87c383d3e9c..6c09ff069b1 100644
--- a/airflow-core/tests/unit/models/test_deadline.py
+++ b/airflow-core/tests/unit/models/test_deadline.py
@@ -19,17 +19,17 @@ from __future__ import annotations
import re
from dataclasses import dataclass
from datetime import datetime, timedelta
+from types import SimpleNamespace
from typing import TYPE_CHECKING
from unittest import mock
import pytest
import time_machine
from sqlalchemy import select
-from sqlalchemy.exc import SQLAlchemyError
from airflow.api_fastapi.core_api.datamodels.dag_run import DAGRunResponse
from airflow.models import DagRun
-from airflow.models.deadline import Deadline, _fetch_from_db
+from airflow.models.deadline import Deadline, ReferenceModels
from airflow.providers.standard.operators.empty import EmptyOperator
from airflow.sdk import timezone
from airflow.sdk.definitions.callback import AsyncCallback, SyncCallback
@@ -42,10 +42,7 @@ from airflow.sdk.definitions.deadline import (
FixedDatetimeDeadline,
deadline_reference,
)
-from airflow.serialization.definitions.deadline import (
- SerializedReferenceModels,
- _fetch_from_db as _fetch_serialized_deadline_from_db,
-)
+from airflow.serialization.definitions.deadline import
SerializedReferenceModels
from airflow.utils.state import DagRunState
from tests_common.test_utils import db
@@ -58,15 +55,6 @@ DAG_ID = "dag_id_1"
INVALID_DAG_ID = "invalid_dag_id"
INVALID_RUN_ID = -1
-REFERENCE_TYPES = [
- pytest.param(SerializedReferenceModels.DagRunLogicalDateDeadline(),
id="logical_date"),
- pytest.param(SerializedReferenceModels.DagRunQueuedAtDeadline(),
id="queued_at"),
-
pytest.param(SerializedReferenceModels.FixedDatetimeDeadline(DEFAULT_DATE),
id="fixed_deadline"),
- pytest.param(
- SerializedReferenceModels.AverageRuntimeDeadline(max_runs=10,
min_runs=10), id="average_runtime"
- ),
-]
-
async def callback_for_deadline():
"""Used in a number of tests to confirm that Deadlines and DeadlineAlerts
function correctly."""
@@ -338,168 +326,41 @@ class TestDeadline:
@pytest.mark.db_test
-class TestCalculatedDeadlineDatabaseCalls:
+class TestCalculatedDeadlineReferences:
@staticmethod
def teardown_method():
_clean_db()
@pytest.mark.parametrize(
- ("column", "conditions", "expected_query"),
- [
- pytest.param(
- DagRun.logical_date,
- {"dag_id": DAG_ID},
- "SELECT dag_run.logical_date \nFROM dag_run \nWHERE
dag_run.dag_id = :dag_id_1",
- id="single_condition_logical_date",
- ),
- pytest.param(
- DagRun.queued_at,
- {"dag_id": DAG_ID},
- "SELECT dag_run.queued_at \nFROM dag_run \nWHERE
dag_run.dag_id = :dag_id_1",
- id="single_condition_queued_at",
- ),
- pytest.param(
- DagRun.logical_date,
- {"dag_id": DAG_ID, "state": "running"},
- "SELECT dag_run.logical_date \nFROM dag_run \nWHERE
dag_run.dag_id = :dag_id_1 AND dag_run.state = :state_1",
- id="multiple_conditions",
- ),
- ],
- )
- @mock.patch("sqlalchemy.orm.Session")
- def test_fetch_from_db_success(self, mock_session, column, conditions,
expected_query):
- """Test successful database queries."""
- mock_session.scalar.return_value = DEFAULT_DATE
-
- result = _fetch_from_db(column, session=mock_session, **conditions)
-
- assert isinstance(result, datetime)
- mock_session.scalar.assert_called_once()
-
- # Check that the correct query was constructed
- call_args = mock_session.scalar.call_args[0][0]
- assert str(call_args) == expected_query
-
- # Verify the actual parameter values
- compiled = call_args.compile()
- for key, value in conditions.items():
- # Note that SQLAlchemy appends the _1 to ensure unique template
field names
- assert compiled.params[f"{key}_1"] == value
-
- @pytest.mark.parametrize(
- ("use_valid_conditions", "scalar_side_effect", "expected_error",
"expected_message"),
- [
- pytest.param(
- False,
- mock.DEFAULT, # This will allow the call to pass through
- AttributeError,
- None,
- id="invalid_attribute",
- ),
- pytest.param(
- True,
- SQLAlchemyError("Database connection failed"),
- SQLAlchemyError,
- "Database connection failed",
- id="database_error",
- ),
- pytest.param(
- True, lambda x: None, ValueError, "No matching record found in
the database", id="no_results"
- ),
- ],
- )
- @mock.patch("sqlalchemy.orm.Session")
- def test_fetch_from_db_error_cases(
- self, mock_session, use_valid_conditions, scalar_side_effect,
expected_error, expected_message
- ):
- """Test database access error handling."""
- model_reference = DagRun.logical_date
- conditions = {"dag_id": "test_dag"} if use_valid_conditions else
{"non_existent_column": "some_value"}
-
- # Configure mock session
- mock_session.scalar.side_effect = scalar_side_effect
-
- with pytest.raises(expected_error, match=expected_message):
- _fetch_from_db(model_reference, session=mock_session, **conditions)
-
- @pytest.mark.parametrize(
- ("reference", "expected_column"),
+ ("reference", "attribute"),
[
pytest.param(
- SerializedReferenceModels.DagRunLogicalDateDeadline(),
DagRun.logical_date, id="logical_date"
- ),
- pytest.param(
- SerializedReferenceModels.DagRunQueuedAtDeadline(),
DagRun.queued_at, id="queued_at"
+ SerializedReferenceModels.DagRunLogicalDateDeadline(),
"logical_date", id="logical_date"
),
+ pytest.param(SerializedReferenceModels.DagRunQueuedAtDeadline(),
"queued_at", id="queued_at"),
pytest.param(
- SerializedReferenceModels.FixedDatetimeDeadline(DEFAULT_DATE),
None, id="fixed_deadline"
- ),
- pytest.param(
- SerializedReferenceModels.AverageRuntimeDeadline(max_runs=10,
min_runs=10),
- None,
- id="average_runtime",
+ ReferenceModels.DagRunLogicalDateDeadline(), "logical_date",
id="legacy_logical_date"
),
+ pytest.param(ReferenceModels.DagRunQueuedAtDeadline(),
"queued_at", id="legacy_queued_at"),
],
)
- def test_deadline_database_integration(self, reference, expected_column,
session):
- """
- Test database integration for all deadline types.
-
- Verifies:
- 1. Calculated deadlines call _fetch_from_db with correct column.
- 2. Fixed deadlines do not interact with database.
- 3. Intervals are added to reference times.
- """
- conditions = {"dag_id": DAG_ID, "run_id": "dagrun_1"}
+ def test_dagrun_references_use_supplied_dagrun(self, reference, attribute,
session):
+ """DagRun references use the in-memory DagRun instead of querying it
again."""
+ dagrun = SimpleNamespace(dag_id=DAG_ID, logical_date=DEFAULT_DATE,
queued_at=DEFAULT_DATE)
interval = timedelta(hours=1)
- with
mock.patch("airflow.serialization.definitions.deadline._fetch_from_db") as
mock_fetch:
- mock_fetch.return_value = DEFAULT_DATE
-
- if expected_column is not None:
- result = reference.evaluate_with(session=session,
interval=interval, **conditions)
- mock_fetch.assert_called_once_with(expected_column,
session=session, **conditions)
- elif isinstance(reference,
SerializedReferenceModels.AverageRuntimeDeadline):
- with mock.patch("airflow._shared.timezones.timezone.utcnow")
as mock_utcnow:
- mock_utcnow.return_value = DEFAULT_DATE
- # No DAG runs exist, so it should use 24-hour default
- result = reference.evaluate_with(session=session,
interval=interval, dag_id=DAG_ID)
- mock_fetch.assert_not_called()
- # Should return None when no DAG runs exist
- assert result is None
- else:
- result = reference.evaluate_with(session=session,
interval=interval)
- mock_fetch.assert_not_called()
- assert result == DEFAULT_DATE + interval
-
- def
test_serialized_fetch_from_db_returns_null_column_without_missing_dagrun_warning(
- self, session, dag_maker, caplog
- ):
- with dag_maker(DAG_ID):
- EmptyOperator(task_id="test_task")
-
- dag_maker.create_dagrun(
- logical_date=None,
- run_id="manual__null_logical_date",
- state=DagRunState.QUEUED,
- )
- session.commit()
- with caplog.at_level("WARNING",
logger="airflow.serialization.definitions.deadline"):
- result = _fetch_serialized_deadline_from_db(
- DagRun.logical_date, session=session, dag_id=DAG_ID,
run_id="manual__null_logical_date"
+ assert getattr(dagrun, attribute) == DEFAULT_DATE
+ assert (
+ reference.evaluate_with(
+ session=session,
+ interval=interval,
+ dagrun=dagrun,
+ dag_id=DAG_ID,
+ run_id="dagrun_1",
+ unexpected="ignored",
)
-
- assert result is None
- assert not any("Could not find DagRun" in record.message for record in
caplog.records)
-
- def test_serialized_fetch_from_db_logs_missing_dagrun(self, session,
caplog):
- with caplog.at_level("WARNING",
logger="airflow.serialization.definitions.deadline"):
- result = _fetch_serialized_deadline_from_db(
- DagRun.logical_date, session=session, dag_id=DAG_ID,
run_id="missing_run"
- )
-
- assert result is None
- assert any("Could not find DagRun" in record.message for record in
caplog.records)
+ == DEFAULT_DATE + interval
+ )
def test_average_runtime_with_sufficient_history(self, session, dag_maker):
"""Test AverageRuntimeDeadline when enough historical data exists."""
@@ -530,7 +391,9 @@ class TestCalculatedDeadlineDatabaseCalls:
with mock.patch("airflow._shared.timezones.timezone.utcnow") as
mock_utcnow:
mock_utcnow.return_value = DEFAULT_DATE
- result = reference.evaluate_with(session=session,
interval=interval, dag_id=DAG_ID)
+ result = reference.evaluate_with(
+ session=session, interval=interval,
dagrun=SimpleNamespace(dag_id=DAG_ID)
+ )
# Calculate expected average: sum(durations) / len(durations)
expected_avg_seconds = sum(durations) / len(durations)
@@ -567,7 +430,9 @@ class TestCalculatedDeadlineDatabaseCalls:
with mock.patch("airflow._shared.timezones.timezone.utcnow") as
mock_utcnow:
mock_utcnow.return_value = DEFAULT_DATE
- result = reference.evaluate_with(session=session,
interval=interval, dag_id=DAG_ID)
+ result = reference.evaluate_with(
+ session=session, interval=interval,
dagrun=SimpleNamespace(dag_id=DAG_ID)
+ )
# Should return None since insufficient runs
assert result is None
@@ -601,7 +466,9 @@ class TestCalculatedDeadlineDatabaseCalls:
with mock.patch("airflow._shared.timezones.timezone.utcnow") as
mock_utcnow:
mock_utcnow.return_value = DEFAULT_DATE
- result = reference.evaluate_with(session=session,
interval=interval, dag_id=DAG_ID)
+ result = reference.evaluate_with(
+ session=session, interval=interval,
dagrun=SimpleNamespace(dag_id=DAG_ID)
+ )
# Should calculate average from 3 runs
expected_avg_seconds = sum(durations) / len(durations) # 4200
seconds
@@ -614,7 +481,9 @@ class TestCalculatedDeadlineDatabaseCalls:
with mock.patch("airflow._shared.timezones.timezone.utcnow") as
mock_utcnow:
mock_utcnow.return_value = DEFAULT_DATE
- result = reference.evaluate_with(session=session,
interval=interval, dag_id=DAG_ID)
+ result = reference.evaluate_with(
+ session=session, interval=interval,
dagrun=SimpleNamespace(dag_id=DAG_ID)
+ )
assert result is None
def test_average_runtime_min_runs_validation(self):
@@ -663,7 +532,9 @@ class TestCalculatedDeadlineDatabaseCalls:
with mock.patch("airflow._shared.timezones.timezone.utcnow") as
mock_utcnow:
mock_utcnow.return_value = DEFAULT_DATE
- result = reference.evaluate_with(session=session,
interval=interval, dag_id=DAG_ID)
+ result = reference.evaluate_with(
+ session=session, interval=interval,
dagrun=SimpleNamespace(dag_id=DAG_ID)
+ )
# Average must be over the 3 successful 60s runs only (not the 36000s
failures).
expected = DEFAULT_DATE + timedelta(seconds=success_duration) +
interval
@@ -687,61 +558,15 @@ class TestCalculatedDeadlineDatabaseCalls:
session.commit()
reference =
SerializedReferenceModels.AverageRuntimeDeadline(max_runs=10, min_runs=3)
- result = reference.evaluate_with(session=session,
interval=timedelta(hours=1), dag_id=DAG_ID)
+ result = reference.evaluate_with(
+ session=session, interval=timedelta(hours=1),
dagrun=SimpleNamespace(dag_id=DAG_ID)
+ )
assert result is None
class TestDeadlineReference:
"""DeadlineReference lives in definitions/deadlines.py but properly
testing them requires DB access."""
- DEFAULT_INTERVAL = timedelta(hours=1)
- DEFAULT_ARGS = {"interval": DEFAULT_INTERVAL}
-
- @pytest.mark.parametrize("reference", REFERENCE_TYPES)
- @pytest.mark.db_test
- def test_deadline_evaluate_with(self, reference, session):
- """Test that all deadline types evaluate correctly with their required
conditions."""
- conditions = {
- "dag_id": DAG_ID,
- "run_id": "dagrun_1",
- "unexpected": "param", # Add an unexpected parameter.
- "extra": "kwarg", # Add another unexpected parameter.
- }
-
- with mock.patch.object(reference, "_evaluate_with") as mock_evaluate:
- mock_evaluate.return_value = DEFAULT_DATE
-
- if reference.required_kwargs:
- result = reference.evaluate_with(**self.DEFAULT_ARGS,
session=session, **conditions)
- else:
- result = reference.evaluate_with(**self.DEFAULT_ARGS,
session=session)
-
- # Verify only expected kwargs are passed through.
- expected_kwargs = {k: conditions[k] for k in
reference.required_kwargs if k in conditions}
- expected_kwargs["session"] = session
-
- mock_evaluate.assert_called_once_with(**expected_kwargs)
-
- assert result == DEFAULT_DATE + self.DEFAULT_INTERVAL
-
- @pytest.mark.parametrize("reference", REFERENCE_TYPES)
- @pytest.mark.db_test
- def test_deadline_missing_required_kwargs(self, reference, session):
- """Test that deadlines raise appropriate errors for missing required
parameters."""
- if reference.required_kwargs:
- with pytest.raises(
- ValueError, match=re.escape(f"{reference.__class__.__name__}
is missing required parameters:")
- ) as raised_exception:
- reference.evaluate_with(session=session, **self.DEFAULT_ARGS)
-
- assert all(substring in str(raised_exception.value) for substring
in reference.required_kwargs)
- else:
- # Let the lack of an exception here effectively assert that no
exception is raised.
- reference.evaluate_with(session=session, **self.DEFAULT_ARGS)
-
- for required_param in reference.required_kwargs:
- assert required_param in str(raised_exception.value)
-
def test_deadline_reference_creation(self):
"""Test that DeadlineReference provides consistent interface and
types."""
fixed_reference = DeadlineReference.FIXED_DATETIME(DEFAULT_DATE)
diff --git a/airflow-core/tests/unit/models/test_deadline_alert.py
b/airflow-core/tests/unit/models/test_deadline_alert.py
index 56ed2f8cc86..54a138b64a0 100644
--- a/airflow-core/tests/unit/models/test_deadline_alert.py
+++ b/airflow-core/tests/unit/models/test_deadline_alert.py
@@ -17,6 +17,7 @@
from __future__ import annotations
from datetime import timedelta
+from types import SimpleNamespace
from unittest.mock import Mock
import pytest
@@ -24,6 +25,7 @@ import time_machine
from sqlalchemy import select
from airflow._shared.timezones import timezone
+from airflow.exceptions import RemovedInAirflow4Warning
from airflow.models.deadline_alert import DeadlineAlert
from airflow.models.serialized_dag import SerializedDagModel
from airflow.sdk.definitions.deadline import BaseDeadlineReference,
DeadlineReference
@@ -218,23 +220,83 @@ class TestDeadlineAlert:
wrapper =
SerializedReferenceModels.SerializedCustomReference(inner_ref)
- wrapper.evaluate_with(
+ with pytest.warns(RemovedInAirflow4Warning, match="required_kwargs is
deprecated"):
+ wrapper.evaluate_with(
+ session=None,
+ interval=timedelta(hours=1),
+ dag_id="test_dag",
+ run_id="test_run",
+ extra_param="should_be_filtered",
+ )
+
+ inner_ref._evaluate_with.assert_called_once_with(session=None,
dag_id="test_dag", run_id="test_run")
+
+ # try calling with missing required parameters
+ with pytest.warns(RemovedInAirflow4Warning, match="required_kwargs is
deprecated"):
+ with pytest.raises(ValueError, match="missing required parameters:
run_id"):
+ wrapper.evaluate_with(
+ session=None,
+ interval=timedelta(hours=1),
+ dag_id="test_dag",
+ )
+
+ def test_serialized_custom_reference_receives_dagrun(self):
+ class DagRunReference(BaseDeadlineReference):
+ def _evaluate_with(self, *, session, dagrun):
+ return dagrun.queued_at
+
+ wrapper =
SerializedReferenceModels.SerializedCustomReference(DagRunReference())
+ dagrun = SimpleNamespace(queued_at=DEFAULT_DATE)
+
+ assert wrapper.evaluate_with(
session=None,
interval=timedelta(hours=1),
+ dagrun=dagrun,
dag_id="test_dag",
run_id="test_run",
- extra_param="should_be_filtered",
- )
+ unexpected="ignored",
+ ) == DEFAULT_DATE + timedelta(hours=1)
- inner_ref._evaluate_with.assert_called_once_with(session=None,
dag_id="test_dag", run_id="test_run")
+ def
test_serialized_custom_reference_receives_dagrun_with_var_keyword_arguments(self):
+ class DagRunReference(BaseDeadlineReference):
+ def _evaluate_with(self, *, session, dagrun, **kwargs):
+ return dagrun.queued_at
- # try calling with missing required parameters
- with pytest.raises(ValueError, match="missing required parameters:
run_id"):
- wrapper.evaluate_with(
- session=None,
- interval=timedelta(hours=1),
- dag_id="test_dag",
- )
+ wrapper =
SerializedReferenceModels.SerializedCustomReference(DagRunReference())
+ dagrun = SimpleNamespace(queued_at=DEFAULT_DATE)
+
+ assert wrapper.evaluate_with(
+ session=None,
+ interval=timedelta(hours=1),
+ dagrun=dagrun,
+ dag_id="test_dag",
+ run_id="test_run",
+ ) == DEFAULT_DATE + timedelta(hours=1)
+
+ def
test_serialized_custom_reference_var_keyword_receives_full_context(self):
+ class VarKeywordReference(BaseDeadlineReference):
+ received_kwargs: dict[str, object]
+
+ def _evaluate_with(self, *, session, **kwargs):
+ self.received_kwargs = kwargs
+ return DEFAULT_DATE
+
+ reference = VarKeywordReference()
+ wrapper =
SerializedReferenceModels.SerializedCustomReference(reference)
+ dagrun = SimpleNamespace()
+
+ assert wrapper.evaluate_with(
+ session=None,
+ interval=timedelta(hours=1),
+ dagrun=dagrun,
+ dag_id="test_dag",
+ run_id="test_run",
+ ) == DEFAULT_DATE + timedelta(hours=1)
+ assert reference.received_kwargs == {
+ "dagrun": dagrun,
+ "dag_id": "test_dag",
+ "run_id": "test_run",
+ }
def test_core_deadline_reference_treated_as_builtins(self):
"""Test that refs from airflow.models.deadline are still treated as
builtins."""
diff --git a/task-sdk/src/airflow/sdk/definitions/deadline.py
b/task-sdk/src/airflow/sdk/definitions/deadline.py
index 3ef17245803..61dbe09b57b 100644
--- a/task-sdk/src/airflow/sdk/definitions/deadline.py
+++ b/task-sdk/src/airflow/sdk/definitions/deadline.py
@@ -47,7 +47,8 @@ class BaseDeadlineReference(ABC):
The actual evaluation logic (``_evaluate_with``) is in Core's
``SerializedReferenceModels``.
For custom deadline references, users should inherit from this class and
implement
- ``_evaluate_with()`` with deferred Core imports (imports inside the method
body). A custom
+ ``_evaluate_with()`` with deferred Core imports (imports inside the method
body). Its
+ keyword-only ``dagrun`` parameter receives the DagRun being evaluated. A
custom
reference must be decorated with ``@deadline_reference`` and listed in the
``deadline_references``
attribute of an ``AirflowPlugin``; see
:external:doc:`howto/deadline-alerts`.
"""
@@ -212,8 +213,8 @@ class DeadlineReference:
The public interface class for all DeadlineReference options.
This class provides a unified interface for working with Deadlines,
supporting both
- calculated deadlines (which fetch values from the database) and fixed
deadlines
- (which return a predefined datetime).
+ calculated deadlines, including references to the DagRun being evaluated
and
+ historical runtime data, and fixed deadlines (which return a predefined
datetime).
------
Usage:
@@ -240,17 +241,15 @@ class DeadlineReference:
),
)
- 3. Evaluating deadlines will ignore unexpected parameters:
+ 3. Custom references receive the DagRun being evaluated:
.. code-block:: python
- # For deadlines requiring parameters:
- deadline = DeadlineReference.DAGRUN_LOGICAL_DATE
- deadline.evaluate_with(dag_id=dag.dag_id)
-
- # For deadlines with no required parameters:
- deadline = DeadlineReference.FIXED_DATETIME(datetime(2025, 5, 4))
- deadline.evaluate_with()
+ class MyDeadlineReference(BaseDeadlineReference):
+ def _evaluate_with(self, *, session, dagrun):
+ # Add your business logic here; dagrun.logical_date is
available to use.
+ my_datetime = my_business_logic(dagrun.logical_date)
+ return my_datetime
"""
class TYPES:
@@ -395,11 +394,10 @@ def deadline_reference(deadline_reference_type=None):
@deadline_reference()
class MyCustomReference(BaseDeadlineReference):
# By default, evaluate_with will be called when a new dagrun is
created.
- def _evaluate_with(self, *, session: Session, **kwargs) ->
datetime:
- # Put your business logic here (use deferred imports for Core
types)
- from airflow.models import DagRun
-
- return some_datetime
+ def _evaluate_with(self, *, session: Session, dagrun) -> datetime:
+ # Add your business logic here; dagrun.logical_date is
available to use.
+ my_datetime = my_business_logic(dagrun.logical_date)
+ return my_datetime
def serialize_reference(self) -> dict:
return {"reference_type": self.reference_name}
@@ -408,9 +406,10 @@ def deadline_reference(deadline_reference_type=None):
# Optionally, specify when it is calculated by providing a
DeadlineReference.TYPES value.
@deadline_reference(DeadlineReference.TYPES.DAGRUN_QUEUED)
class MyQueuedRef(BaseDeadlineReference):
- def _evaluate_with(self, *, session: Session, **kwargs) ->
datetime:
- # Put your business logic here
- return some_datetime
+ def _evaluate_with(self, *, session: Session, dagrun) -> datetime:
+ # Add your business logic here; dagrun.queued_at is available
to use.
+ my_datetime = my_business_logic(dagrun.queued_at)
+ return my_datetime
def serialize_reference(self) -> dict:
return {"reference_type": self.reference_name}