Skip to content

Commit a66b446

Browse files
committed
Re-anchor custom queued deadline references when a Dag run is cleared
The evaluation timing a reference is registered with only lived in the in-memory registry of the process that ran the Dag file, so the scheduler had no way to tell a queued-anchored reference from any other and fell back to matching the built-in class name. Custom references registered as DeadlineReference.TYPES.DAGRUN_QUEUED were silently left pointing at the old queued_at. closes: #71747
1 parent 9c8d751 commit a66b446

10 files changed

Lines changed: 203 additions & 16 deletions

File tree

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
Custom deadline references registered as ``DeadlineReference.TYPES.DAGRUN_QUEUED`` are now re-anchored to the new queued time when a Dag run is cleared, as the built-in ``DeadlineReference.DAGRUN_QUEUED_AT`` already was. Deadline alerts serialized before this fix pick the behaviour up once their Dag is parsed again.

‎airflow-core/src/airflow/models/deadline.py‎

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,11 @@
5656

5757
CALLBACK_METRICS_PREFIX = "deadline_alerts"
5858

59+
# Must be in sync with the identically named constants in airflow.sdk.definitions.deadline
60+
EVALUATION_TIMING_FIELD = "evaluation_timing"
61+
DAGRUN_CREATED_TIMING = "DAGRUN_CREATED"
62+
DAGRUN_QUEUED_TIMING = "DAGRUN_QUEUED"
63+
5964

6065
class classproperty:
6166
"""
@@ -325,6 +330,7 @@ class BaseDeadlineReference(LoggingMixin, ABC):
325330

326331
# Set of required kwargs - subclasses should override this.
327332
required_kwargs: set[str] = set()
333+
evaluation_timing: str = DAGRUN_CREATED_TIMING
328334

329335
@classproperty
330336
def reference_name(cls: Any) -> str:
@@ -411,6 +417,7 @@ class DagRunQueuedAtDeadline(BaseDeadlineReference):
411417
"""A deadline that returns when a DagRun was queued."""
412418

413419
required_kwargs = {"dag_id", "run_id"}
420+
evaluation_timing: str = DAGRUN_QUEUED_TIMING
414421

415422
@provide_session
416423
def _evaluate_with(self, *, session: Session, **kwargs: Any) -> datetime | None:

‎airflow-core/src/airflow/models/taskinstance.py‎

Lines changed: 13 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -84,7 +84,12 @@
8484
from airflow.models.asset import AssetModel
8585
from airflow.models.base import Base, StringID, TaskInstanceDependencies
8686
from airflow.models.dag_version import DagVersion
87-
from airflow.models.deadline import Deadline, ReferenceModels
87+
from airflow.models.deadline import (
88+
DAGRUN_QUEUED_TIMING,
89+
EVALUATION_TIMING_FIELD,
90+
Deadline,
91+
ReferenceModels,
92+
)
8893
from airflow.models.deadline_alert import DeadlineAlert as DeadlineAlertModel
8994

9095
# Import HITLDetail at runtime so SQLAlchemy can resolve the relationship
@@ -240,8 +245,13 @@ def _recalculate_dagrun_queued_at_deadlines(
240245
.where(
241246
Deadline.dagrun_id == dagrun.id,
242247
Deadline.missed == false(),
243-
DeadlineAlertModel.reference[ReferenceModels.REFERENCE_TYPE_FIELD].as_string()
244-
== ReferenceModels.DagRunQueuedAtDeadline.__name__,
248+
or_(
249+
DeadlineAlertModel.reference[EVALUATION_TIMING_FIELD].as_string() == DAGRUN_QUEUED_TIMING,
250+
# Alerts serialized before the timing was persisted only identify the built-in
251+
# queued reference by name.
252+
DeadlineAlertModel.reference[ReferenceModels.REFERENCE_TYPE_FIELD].as_string()
253+
== ReferenceModels.DagRunQueuedAtDeadline.__name__,
254+
),
245255
)
246256
).all()
247257

‎airflow-core/src/airflow/serialization/definitions/deadline.py‎

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,7 @@
2727
from sqlalchemy import select
2828

2929
from airflow._shared.timezones import timezone
30-
from airflow.models.deadline import classproperty
30+
from airflow.models.deadline import DAGRUN_CREATED_TIMING, DAGRUN_QUEUED_TIMING, classproperty
3131
from airflow.utils.log.logging_mixin import LoggingMixin
3232
from airflow.utils.session import provide_session
3333
from airflow.utils.sqlalchemy import get_dialect_name
@@ -97,6 +97,7 @@ class SerializedBaseDeadlineReference(LoggingMixin, ABC):
9797
"""Base class for all serialized Deadline implementations."""
9898

9999
required_kwargs: set[str] = set()
100+
evaluation_timing: str = DAGRUN_CREATED_TIMING
100101

101102
@classproperty
102103
def reference_name(cls: Any) -> str:
@@ -180,6 +181,7 @@ class DagRunQueuedAtDeadline(SerializedBaseDeadlineReference):
180181
"""A deadline that returns when a DagRun was queued."""
181182

182183
required_kwargs = {"dag_id", "run_id"}
184+
evaluation_timing: str = DAGRUN_QUEUED_TIMING
183185

184186
@provide_session
185187
def _evaluate_with(self, *, session: Session, **kwargs: Any) -> datetime | None:
@@ -285,6 +287,7 @@ class SerializedCustomReference(SerializedBaseDeadlineReference):
285287

286288
def __init__(self, inner_ref):
287289
self.inner_ref = inner_ref
290+
self.evaluation_timing = getattr(inner_ref, "evaluation_timing", DAGRUN_CREATED_TIMING)
288291

289292
@property
290293
def reference_name(self) -> str:

‎airflow-core/src/airflow/serialization/encoders.py‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@
2626
import pendulum
2727

2828
from airflow._shared.module_loading import qualname
29+
from airflow.models.deadline import DAGRUN_CREATED_TIMING, EVALUATION_TIMING_FIELD
2930
from airflow.partition_mappers.base import PartitionMapper as CorePartitionMapper
3031
from airflow.partition_mappers.wait_policy import WaitPolicy as CoreWaitPolicy
3132
from airflow.partition_mappers.window import Window as CoreWindow
@@ -277,6 +278,10 @@ def encode_deadline_reference(ref) -> dict[str, Any]:
277278

278279
serialized = ref.serialize_reference()
279280

281+
# Added here rather than in serialize_reference() because custom references are expected to
282+
# override that method, and an override would drop the timing.
283+
serialized[EVALUATION_TIMING_FIELD] = getattr(ref, "evaluation_timing", DAGRUN_CREATED_TIMING)
284+
280285
# Custom types (not built-in) need __class_path so the decoder can look them up.
281286
# Unlike built-in types which are looked up in SerializedReferenceModels,
282287
# custom types are resolved at deserialization time from the classes registered

‎airflow-core/tests/unit/models/test_deadline.py‎

Lines changed: 8 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,8 @@
3434
from airflow.sdk import timezone
3535
from airflow.sdk.definitions.callback import AsyncCallback, SyncCallback
3636
from airflow.sdk.definitions.deadline import (
37+
DAGRUN_CREATED_TIMING,
38+
DAGRUN_QUEUED_TIMING,
3739
AverageRuntimeDeadline,
3840
BaseDeadlineReference,
3941
DagRunLogicalDateDeadline,
@@ -769,14 +771,14 @@ def teardown_method(self):
769771
],
770772
)
771773
@pytest.mark.parametrize(
772-
"timing",
774+
("timing", "expected_evaluation_timing"),
773775
[
774-
pytest.param(None, id="default_timing"),
775-
pytest.param(DeadlineReference.TYPES.DAGRUN_CREATED, id="dagrun_created"),
776-
pytest.param(DeadlineReference.TYPES.DAGRUN_QUEUED, id="dagrun_queued"),
776+
pytest.param(None, DAGRUN_CREATED_TIMING, id="default_timing"),
777+
pytest.param(DeadlineReference.TYPES.DAGRUN_CREATED, DAGRUN_CREATED_TIMING, id="dagrun_created"),
778+
pytest.param(DeadlineReference.TYPES.DAGRUN_QUEUED, DAGRUN_QUEUED_TIMING, id="dagrun_queued"),
777779
],
778780
)
779-
def test_register_custom_reference(self, timing, reference):
781+
def test_register_custom_reference(self, timing, expected_evaluation_timing, reference):
780782
if timing is None:
781783
result = DeadlineReference.register_custom_reference(reference)
782784
expected_timing = DeadlineReference.TYPES.DAGRUN_CREATED
@@ -787,6 +789,7 @@ def test_register_custom_reference(self, timing, reference):
787789
assert result is reference
788790
assert hasattr(DeadlineReference, reference.__name__)
789791
assert getattr(DeadlineReference, reference.__name__).__class__ is reference
792+
assert reference.evaluation_timing == expected_evaluation_timing
790793

791794
assert_correct_timing(reference, expected_timing)
792795
assert_builtin_types_unchanged(

‎airflow-core/tests/unit/models/test_deadline_alert.py‎

Lines changed: 53 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -24,10 +24,20 @@
2424
from sqlalchemy import select
2525

2626
from airflow._shared.timezones import timezone
27+
from airflow.models import deadline as deadline_models
28+
from airflow.models.deadline import ReferenceModels
2729
from airflow.models.deadline_alert import DeadlineAlert
2830
from airflow.models.serialized_dag import SerializedDagModel
29-
from airflow.sdk.definitions.deadline import BaseDeadlineReference, DeadlineReference
31+
from airflow.sdk.definitions.deadline import (
32+
DAGRUN_CREATED_TIMING,
33+
DAGRUN_QUEUED_TIMING,
34+
EVALUATION_TIMING_FIELD,
35+
REFERENCE_TYPE_FIELD,
36+
BaseDeadlineReference,
37+
DeadlineReference,
38+
)
3039
from airflow.serialization.definitions.deadline import SerializedReferenceModels
40+
from airflow.serialization.encoders import encode_deadline_reference
3141

3242
from tests_common.test_utils import db
3343
from unit.models import DEFAULT_DATE
@@ -39,6 +49,24 @@
3949
SERIALIZED_DAG_ID = "serialized_dag_uuid"
4050

4151

52+
class CustomQueuedReference(BaseDeadlineReference):
53+
"""A queued-anchored custom reference which overrides serialize_reference, as the docs suggest."""
54+
55+
evaluation_timing = DAGRUN_QUEUED_TIMING
56+
57+
def serialize_reference(self) -> dict:
58+
return {REFERENCE_TYPE_FIELD: self.reference_name}
59+
60+
61+
def test_core_timing_constants_match_the_sdk():
62+
"""Core cannot import these from the SDK, so guard the copies against drift."""
63+
assert (
64+
deadline_models.EVALUATION_TIMING_FIELD,
65+
deadline_models.DAGRUN_CREATED_TIMING,
66+
deadline_models.DAGRUN_QUEUED_TIMING,
67+
) == (EVALUATION_TIMING_FIELD, DAGRUN_CREATED_TIMING, DAGRUN_QUEUED_TIMING)
68+
69+
4270
def _clean_db():
4371
db.clear_db_deadline_alert()
4472

@@ -236,11 +264,32 @@ def _evaluate_with(self, *, session, dag_id, run_id):
236264
dag_id="test_dag",
237265
)
238266

267+
@pytest.mark.parametrize(
268+
("reference", "expected_timing"),
269+
[
270+
pytest.param(DeadlineReference.DAGRUN_QUEUED_AT, DAGRUN_QUEUED_TIMING, id="sdk_queued"),
271+
pytest.param(DeadlineReference.DAGRUN_LOGICAL_DATE, DAGRUN_CREATED_TIMING, id="sdk_created"),
272+
pytest.param(CustomQueuedReference(), DAGRUN_QUEUED_TIMING, id="custom_queued"),
273+
pytest.param(
274+
SerializedReferenceModels.SerializedCustomReference(CustomQueuedReference()),
275+
DAGRUN_QUEUED_TIMING,
276+
id="wrapped_custom_queued",
277+
),
278+
pytest.param(
279+
SerializedReferenceModels.DagRunQueuedAtDeadline(),
280+
DAGRUN_QUEUED_TIMING,
281+
id="serialized_queued",
282+
),
283+
pytest.param(
284+
ReferenceModels.DagRunQueuedAtDeadline(), DAGRUN_QUEUED_TIMING, id="core_legacy_queued"
285+
),
286+
],
287+
)
288+
def test_encoded_reference_carries_evaluation_timing(self, reference, expected_timing):
289+
assert encode_deadline_reference(reference)[EVALUATION_TIMING_FIELD] == expected_timing
290+
239291
def test_core_deadline_reference_treated_as_builtins(self):
240292
"""Test that refs from airflow.models.deadline are still treated as builtins."""
241-
from airflow.models.deadline import ReferenceModels
242-
from airflow.serialization.encoders import encode_deadline_reference
243-
244293
ref = ReferenceModels.DagRunLogicalDateDeadline()
245294
serialized = encode_deadline_reference(ref)
246295

‎airflow-core/tests/unit/models/test_serialized_dag.py‎

Lines changed: 25 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -41,7 +41,12 @@
4141
from airflow.providers.standard.operators.python import PythonOperator
4242
from airflow.sdk import DAG, Asset, AssetAlias, task as task_decorator
4343
from airflow.sdk.definitions.callback import AsyncCallback
44-
from airflow.sdk.definitions.deadline import DeadlineAlert, DeadlineReference
44+
from airflow.sdk.definitions.deadline import (
45+
DAGRUN_QUEUED_TIMING,
46+
EVALUATION_TIMING_FIELD,
47+
DeadlineAlert,
48+
DeadlineReference,
49+
)
4550
from airflow.serialization.dag_dependency import DagDependency
4651
from airflow.serialization.definitions.dag import SerializedDAG
4752
from airflow.serialization.serialized_objects import DagSerialization, LazyDeserializedDAG
@@ -1265,6 +1270,25 @@ def test_write_dag_with_deadline_passes_schema_validation(self, testing_dag_bund
12651270
# Must not raise: the stored UUID-reference form has to satisfy the serialized Dag schema.
12661271
DagSerialization.validate_schema(result.data)
12671272

1273+
def test_write_dag_persists_evaluation_timing(self, testing_dag_bundle, session):
1274+
"""The stored alert must record when the reference is anchored, not just its class name."""
1275+
dag_id = "test_deadline_evaluation_timing"
1276+
dag = DAG(
1277+
dag_id=dag_id,
1278+
deadline=DeadlineAlert(
1279+
reference=DeadlineReference.DAGRUN_QUEUED_AT,
1280+
interval=timedelta(minutes=5),
1281+
callback=AsyncCallback(empty_callback_for_deadline),
1282+
),
1283+
)
1284+
EmptyOperator(task_id="task1", dag=dag)
1285+
sync_dag_to_db(dag, session=session)
1286+
session.commit()
1287+
1288+
serialized_dag = session.scalar(select(SDM).where(SDM.dag_id == dag_id))
1289+
alert = session.scalar(select(DAM).where(DAM.serialized_dag_id == serialized_dag.id))
1290+
assert alert.reference[EVALUATION_TIMING_FIELD] == DAGRUN_QUEUED_TIMING
1291+
12681292
def test_write_dag_does_not_mutate_caller_deadline_data(self, testing_dag_bundle, session):
12691293
"""write_dag must not rewrite the caller's LazyDeserializedDAG deadline in place.
12701294

‎airflow-core/tests/unit/models/test_taskinstance.py‎

Lines changed: 72 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -91,14 +91,19 @@
9191
)
9292
from airflow.sdk.api.datamodels._generated import AssetEventResponse, AssetResponse
9393
from airflow.sdk.definitions.callback import AsyncCallback
94-
from airflow.sdk.definitions.deadline import DeadlineReference
94+
from airflow.sdk.definitions.deadline import (
95+
DAGRUN_CREATED_TIMING,
96+
DAGRUN_QUEUED_TIMING,
97+
BaseDeadlineReference,
98+
DeadlineReference,
99+
)
95100
from airflow.sdk.definitions.param import process_params
96101
from airflow.sdk.definitions.taskgroup import TaskGroup
97102
from airflow.sdk.execution_time.comms import AssetEventsResult
98103
from airflow.serialization.definitions.assets import SerializedAsset
99104
from airflow.serialization.definitions.baseoperator import SerializedBaseOperator
100105
from airflow.serialization.definitions.dag import SerializedDAG
101-
from airflow.serialization.encoders import ensure_serialized_asset
106+
from airflow.serialization.encoders import encode_deadline_reference, ensure_serialized_asset
102107
from airflow.serialization.serialized_objects import OperatorSerialization, create_scheduler_operator
103108
from airflow.ti_deps.dep_context import DepContext
104109
from airflow.ti_deps.dependencies_deps import REQUEUEABLE_DEPS, RUNNING_DEPS
@@ -4190,6 +4195,71 @@ def test_clear_task_instances_recalculates_dagrun_queued_deadlines(dag_maker, se
41904195
assert recalculated_count == 2
41914196

41924197

4198+
@pytest.mark.parametrize(
4199+
("timing", "should_recalculate"),
4200+
[
4201+
pytest.param(DAGRUN_QUEUED_TIMING, True, id="queued"),
4202+
pytest.param(DAGRUN_CREATED_TIMING, False, id="created"),
4203+
],
4204+
)
4205+
def test_clear_task_instances_recalculates_custom_queued_deadlines(
4206+
dag_maker, session, timing, should_recalculate
4207+
):
4208+
"""Test that clearing tasks recalculates custom deadlines registered as queued-anchored."""
4209+
4210+
class CustomReference(BaseDeadlineReference):
4211+
evaluation_timing = timing
4212+
4213+
with dag_maker(
4214+
dag_id="test_recalculate_custom_deadlines",
4215+
schedule=datetime.timedelta(days=1),
4216+
) as dag:
4217+
EmptyOperator(task_id="task_1")
4218+
4219+
dag_run = dag_maker.create_dagrun()
4220+
ti = dag_run.get_task_instance("task_1", session=session)
4221+
ti.set_state(TaskInstanceState.SUCCESS, session=session)
4222+
4223+
original_queued_at = timezone.utcnow() - datetime.timedelta(hours=2)
4224+
dag_run.queued_at = original_queued_at
4225+
session.flush()
4226+
4227+
interval = datetime.timedelta(hours=1)
4228+
deadline_alert = DeadlineAlertModel(
4229+
serialized_dag_id=session.scalar(
4230+
select(SerializedDagModel.id).where(SerializedDagModel.dag_id == dag.dag_id)
4231+
),
4232+
reference=encode_deadline_reference(CustomReference()),
4233+
interval=interval.total_seconds(),
4234+
callback_def={"path": f"{__name__}.empty_callback_for_deadline", "kwargs": {}},
4235+
)
4236+
session.add(deadline_alert)
4237+
session.flush()
4238+
4239+
original_deadline_time = original_queued_at + interval
4240+
session.add(
4241+
Deadline(
4242+
dagrun_id=dag_run.id,
4243+
deadline_alert_id=deadline_alert.id,
4244+
deadline_time=original_deadline_time,
4245+
callback=AsyncCallback(empty_callback_for_deadline),
4246+
dag_id=dag_run.dag_id,
4247+
)
4248+
)
4249+
session.flush()
4250+
4251+
tis = session.scalars(select(TI).where(TI.dag_id == dag.dag_id, TI.run_id == dag_run.run_id)).all()
4252+
clear_task_instances(tis, session)
4253+
4254+
dag_run = session.scalar(select(DagRun).where(DagRun.id == dag_run.id))
4255+
deadline = session.scalar(select(Deadline).where(Deadline.dagrun_id == dag_run.id))
4256+
4257+
if should_recalculate:
4258+
assert deadline.deadline_time == dag_run.queued_at + interval
4259+
else:
4260+
assert deadline.deadline_time == original_deadline_time
4261+
4262+
41934263
def test_get_dagrun_loaded_but_none_returns_dagrun(dag_maker, session):
41944264
"""
41954265
Test that `get_dagrun()` fetches `DagRun` from DB when the `dag_run`

0 commit comments

Comments
 (0)