Skip to content

Commit e00e532

Browse files
Add SerializedVariableInterval for deadline alerts (#71802)
1 parent ff601cb commit e00e532

10 files changed

Lines changed: 156 additions & 28 deletions

File tree

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
Fixed ``VariableInterval`` deadline intervals to allow zero and negative offsets, matching the existing ``timedelta`` interval semantics. ``VariableInterval`` is now converted to the core-side ``SerializedVariableInterval`` representation during deadline deserialization and resolved during deadline evaluation.

airflow-core/src/airflow/serialization/decoders.py

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@
3838
DeadlineAlertFields,
3939
SerializedDeadlineAlert,
4040
SerializedReferenceModels,
41+
SerializedVariableInterval,
4142
)
4243
from airflow.serialization.enums import DagAttributeTypes as DAT, Encoding
4344
from airflow.serialization.helpers import (
@@ -203,16 +204,21 @@ def decode_deadline_alert(encoded_data: dict):
203204
"from a version that supports VariableInterval. Downgrade is not fully reversible."
204205
)
205206

206-
interval: datetime.timedelta | VariableInterval
207+
interval: datetime.timedelta | SerializedVariableInterval
207208

208209
# Backward compatibility: previously interval was stored as total_seconds() (float/int).
209210
# Handle numeric values by converting to timedelta.
210211
if isinstance(raw_interval, (int, float)):
211212
interval = datetime.timedelta(seconds=raw_interval)
212213
else:
213214
deserialized = deserialize(raw_interval)
214-
if isinstance(deserialized, (datetime.timedelta, VariableInterval)):
215+
216+
if isinstance(deserialized, datetime.timedelta):
217+
interval = deserialized
218+
elif isinstance(deserialized, SerializedVariableInterval):
215219
interval = deserialized
220+
elif isinstance(deserialized, VariableInterval):
221+
interval = SerializedVariableInterval(key=deserialized.key)
216222
else:
217223
raise TypeError(f"Invalid interval type: {type(deserialized).__name__}")
218224

airflow-core/src/airflow/serialization/definitions/dag.py

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -50,9 +50,12 @@
5050
from airflow.models.deadline_alert import DeadlineAlert as DeadlineAlertModel
5151
from airflow.models.taskinstancekey import TaskInstanceKey
5252
from airflow.models.tasklog import LogTemplate
53-
from airflow.sdk.definitions.deadline import VariableInterval
5453
from airflow.serialization.decoders import decode_deadline_alert
55-
from airflow.serialization.definitions.deadline import DeadlineAlertFields, SerializedReferenceModels
54+
from airflow.serialization.definitions.deadline import (
55+
DeadlineAlertFields,
56+
SerializedReferenceModels,
57+
SerializedVariableInterval,
58+
)
5659
from airflow.serialization.definitions.param import SerializedParamsDict
5760
from airflow.serialization.enums import DagAttributeTypes as DAT, Encoding
5861
from airflow.timetables.base import DagRunInfo, DataInterval, TimeRestriction
@@ -754,7 +757,7 @@ def _process_dagrun_deadline_alerts(
754757

755758
interval = deserialized_deadline_alert.interval
756759

757-
if isinstance(interval, VariableInterval):
760+
if isinstance(interval, SerializedVariableInterval):
758761
interval = interval.resolve()
759762

760763
if isinstance(deserialized_deadline_alert.reference, SerializedReferenceModels.TYPES.DAGRUN):

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

Lines changed: 25 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@
2828

2929
from airflow._shared.timezones import timezone
3030
from airflow.models.deadline import classproperty
31+
from airflow.models.variable import Variable
3132
from airflow.utils.log.logging_mixin import LoggingMixin
3233
from airflow.utils.session import provide_session
3334
from airflow.utils.sqlalchemy import get_dialect_name
@@ -38,8 +39,6 @@
3839
from sqlalchemy import ColumnElement
3940
from sqlalchemy.orm import Session
4041

41-
from airflow.sdk.definitions.deadline import VariableInterval
42-
4342
logger = logging.getLogger(__name__)
4443

4544

@@ -380,11 +379,34 @@ def _fetch_from_db(column, *, session: Session, dag_id: str, run_id: str) -> dat
380379
return result
381380

382381

382+
@attrs.define(frozen=True)
383+
class SerializedVariableInterval:
384+
"""Core-side serialized representation of a variable-backed deadline interval."""
385+
386+
key: str
387+
388+
def resolve(self) -> timedelta:
389+
390+
try:
391+
value = Variable.get(self.key)
392+
except KeyError as e:
393+
raise ValueError(f"VariableInterval '{self.key}' not found") from e
394+
395+
try:
396+
seconds = int(value)
397+
except (TypeError, ValueError) as e:
398+
raise ValueError(
399+
f"VariableInterval '{self.key}' must be an integer (seconds), got: {value!r}"
400+
) from e
401+
402+
return timedelta(seconds=seconds)
403+
404+
383405
@attrs.define
384406
class SerializedDeadlineAlert:
385407
"""Serialized representation of a deadline alert."""
386408

387409
reference: SerializedReferenceModels.SerializedBaseDeadlineReference
388-
interval: timedelta | VariableInterval
410+
interval: timedelta | SerializedVariableInterval
389411
callback: Any
390412
name: str | None = None

airflow-core/tests/unit/models/test_dagrun.py

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,7 @@
6161
from airflow.models.taskmap import TaskMap
6262
from airflow.models.taskreschedule import TaskReschedule
6363
from airflow.models.trigger import Trigger
64+
from airflow.models.variable import Variable
6465
from airflow.providers.standard.operators.bash import BashOperator
6566
from airflow.providers.standard.operators.empty import EmptyOperator
6667
from airflow.providers.standard.operators.python import PythonOperator, ShortCircuitOperator
@@ -76,8 +77,6 @@
7677
)
7778
from airflow.sdk.definitions.callback import AsyncCallback
7879
from airflow.sdk.definitions.deadline import DeadlineAlert, DeadlineReference, VariableInterval
79-
from airflow.sdk.definitions.variable import Variable
80-
from airflow.sdk.exceptions import AirflowRuntimeError
8180
from airflow.serialization.definitions.deadline import SerializedReferenceModels
8281
from airflow.serialization.serialized_objects import LazyDeserializedDAG
8382
from airflow.settings import get_policy_plugin_manager
@@ -1532,7 +1531,7 @@ def test_dagrun_deadline_variable_interval_stable(self, _, mock_get, session, de
15321531
)
15331532
dag_run.dag = scheduler_dag
15341533

1535-
# First update resolve interval to "5".
1534+
# First update resolves interval to "60".
15361535
dag_run.update_state(session=session)
15371536

15381537
deadline = session.execute(select(Deadline)).scalars().one_or_none()
@@ -1556,7 +1555,7 @@ def test_dagrun_deadline_variable_interval_missing_variable_fails(self, _, sessi
15561555
with mock.patch.object(
15571556
Variable,
15581557
"get",
1559-
side_effect=AirflowRuntimeError(mock_err),
1558+
side_effect=KeyError(mock_err),
15601559
):
15611560
future_date = datetime.datetime.now() + datetime.timedelta(days=365)
15621561

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
# Licensed to the Apache Software Foundation (ASF) under one
2+
# or more contributor license agreements. See the NOTICE file
3+
# distributed with this work for additional information
4+
# regarding copyright ownership. The ASF licenses this file
5+
# to you under the Apache License, Version 2.0 (the
6+
# "License"); you may not use this file except in compliance
7+
# with the License. You may obtain a copy of the License at
8+
#
9+
# http://www.apache.org/licenses/LICENSE-2.0
10+
#
11+
# Unless required by applicable law or agreed to in writing,
12+
# software distributed under the License is distributed on an
13+
# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
14+
# KIND, either express or implied. See the License for the
15+
# specific language governing permissions and limitations
16+
# under the License.
17+
18+
from __future__ import annotations
19+
20+
from datetime import timedelta
21+
22+
import pytest
23+
24+
from airflow.models.variable import Variable
25+
from airflow.serialization.definitions.deadline import SerializedVariableInterval
26+
27+
28+
class TestVariableInterval:
29+
@pytest.mark.parametrize(
30+
("value", "expected"),
31+
[
32+
("3", timedelta(seconds=3)),
33+
("10", timedelta(seconds=10)),
34+
("05", timedelta(seconds=5)),
35+
("0", timedelta(0)),
36+
("-5", timedelta(seconds=-5)),
37+
],
38+
)
39+
def test_resolve_valid(self, mocker, value, expected):
40+
mocker.patch.object(Variable, "get", return_value=value)
41+
42+
interval = SerializedVariableInterval(key="test_interval")
43+
44+
assert interval.resolve() == expected
45+
46+
@pytest.mark.parametrize(
47+
("value", "raise_missing", "match"),
48+
[
49+
(None, True, "not found"),
50+
("abc", False, "must be an integer"),
51+
("", False, "must be an integer"),
52+
],
53+
)
54+
def test_resolve_invalid(self, mocker, value, raise_missing, match):
55+
if raise_missing:
56+
mocker.patch.object(
57+
Variable,
58+
"get",
59+
side_effect=KeyError("test_interval"),
60+
)
61+
else:
62+
mocker.patch.object(Variable, "get", return_value=value)
63+
64+
interval = SerializedVariableInterval(key="test_interval")
65+
66+
with pytest.raises(ValueError, match=match):
67+
interval.resolve()

airflow-core/tests/unit/serialization/test_serialized_objects.py

Lines changed: 29 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -79,6 +79,7 @@
7979
AsyncCallback,
8080
DeadlineAlert,
8181
DeadlineReference,
82+
VariableInterval,
8283
)
8384
from airflow.sdk.definitions.decorators import task
8485
from airflow.sdk.definitions.operator_resources import Resources
@@ -95,7 +96,11 @@
9596
SerializedAssetBase,
9697
SerializedAssetRef,
9798
)
98-
from airflow.serialization.definitions.deadline import DeadlineAlertFields, SerializedDeadlineAlert
99+
from airflow.serialization.definitions.deadline import (
100+
DeadlineAlertFields,
101+
SerializedDeadlineAlert,
102+
SerializedVariableInterval,
103+
)
99104
from airflow.serialization.encoders import ensure_serialized_asset, ensure_serialized_deadline_alert
100105
from airflow.serialization.enums import DagAttributeTypes as DAT, Encoding
101106
from airflow.serialization.helpers import PartitionMapperNotFound
@@ -492,13 +497,33 @@ def test_serialize_deserialize_connection():
492497

493498

494499
@pytest.mark.parametrize("reference", REFERENCE_TYPES)
495-
def test_serialize_deserialize_deadline_alert(reference):
500+
@pytest.mark.parametrize(
501+
("interval", "expected_interval"),
502+
[
503+
pytest.param(
504+
timedelta(hours=1),
505+
timedelta(hours=1),
506+
id="timedelta",
507+
),
508+
pytest.param(
509+
VariableInterval("deadline_seconds"),
510+
SerializedVariableInterval("deadline_seconds"),
511+
id="sdk_variable_interval",
512+
),
513+
pytest.param(
514+
SerializedVariableInterval("deadline_seconds"),
515+
SerializedVariableInterval("deadline_seconds"),
516+
id="serialized_variable_interval",
517+
),
518+
],
519+
)
520+
def test_serialize_deserialize_deadline_alert(reference, interval, expected_interval):
496521
public_deadline_alert_fields = {
497522
field.lower() for field in vars(DeadlineAlertFields) if not field.startswith("_")
498523
}
499524
original = DeadlineAlert(
500525
reference=reference,
501-
interval=timedelta(hours=1),
526+
interval=interval,
502527
callback=AsyncCallback(empty_callback_for_deadline, kwargs=TEST_CALLBACK_KWARGS),
503528
)
504529

@@ -509,7 +534,7 @@ def test_serialize_deserialize_deadline_alert(reference):
509534

510535
deserialized = BaseSerialization.deserialize(serialized)
511536
assert deserialized.reference.serialize_reference() == reference.serialize_reference()
512-
assert deserialized.interval == original.interval
537+
assert deserialized.interval == expected_interval
513538
assert deserialized.callback == original.callback
514539

515540

generated/known_sdk_imports_in_core.txt

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -25,8 +25,7 @@ airflow-core/src/airflow/providers_manager.py::5
2525
airflow-core/src/airflow/secrets/__init__.py::1
2626
airflow-core/src/airflow/serialization/decoders.py::2
2727
airflow-core/src/airflow/serialization/definitions/baseoperator.py::1
28-
airflow-core/src/airflow/serialization/definitions/dag.py::2
29-
airflow-core/src/airflow/serialization/definitions/deadline.py::1
28+
airflow-core/src/airflow/serialization/definitions/dag.py::1
3029
airflow-core/src/airflow/serialization/definitions/mappedoperator.py::5
3130
airflow-core/src/airflow/serialization/encoders.py::11
3231
airflow-core/src/airflow/serialization/serialized_objects.py::17

task-sdk/src/airflow/sdk/definitions/deadline.py

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@
1717
from __future__ import annotations
1818

1919
import logging
20+
import warnings
2021
from abc import ABC
2122
from dataclasses import dataclass
2223
from datetime import datetime, timedelta
@@ -438,6 +439,13 @@ class VariableInterval:
438439
key: str
439440

440441
def resolve(self) -> timedelta:
442+
warnings.warn(
443+
"VariableInterval.resolve() is deprecated and will be removed in a future release. "
444+
"Deadline interval resolution is handled internally during deadline evaluation.",
445+
DeprecationWarning,
446+
stacklevel=2,
447+
)
448+
441449
try:
442450
value = Variable.get(self.key)
443451
except AirflowRuntimeError as e:
@@ -450,7 +458,4 @@ def resolve(self) -> timedelta:
450458
f"VariableInterval '{self.key}' must be an integer (seconds), got: {value!r}"
451459
) from e
452460

453-
if seconds <= 0:
454-
raise ValueError(f"VariableInterval '{self.key}' must be > 0, got: {seconds}")
455-
456461
return timedelta(seconds=seconds)

task-sdk/tests/task_sdk/definitions/test_deadline.py

Lines changed: 8 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -173,28 +173,28 @@ class TestVariableInterval:
173173
[
174174
("3", timedelta(seconds=3)),
175175
("10", timedelta(seconds=10)),
176-
("05", timedelta(seconds=5)), # leading zero
176+
("05", timedelta(seconds=5)),
177+
("0", timedelta(0)),
178+
("-5", timedelta(seconds=-5)),
177179
],
178180
)
179181
def test_resolve_valid(self, mocker, value, expected):
180182
mocker.patch.object(Variable, "get", return_value=value)
181183

182184
interval = VariableInterval(key="test_interval")
183185

184-
assert interval.resolve() == expected
186+
with pytest.warns(DeprecationWarning, match="VariableInterval.resolve"):
187+
assert interval.resolve() == expected
185188

186189
@pytest.mark.parametrize(
187190
("value", "raise_runtime", "match"),
188191
[
189192
(None, True, "not found"),
190193
("abc", False, "must be an integer"),
191194
("", False, "must be an integer"),
192-
("0", False, "must be > 0"),
193-
("-5", False, "must be > 0"),
194195
],
195196
)
196197
def test_resolve_invalid(self, mocker, value, raise_runtime, match):
197-
198198
if raise_runtime:
199199
mock_err = mock.Mock()
200200
mock_err.error.value = "MISSING"
@@ -210,5 +210,6 @@ def test_resolve_invalid(self, mocker, value, raise_runtime, match):
210210

211211
interval = VariableInterval(key="test_interval")
212212

213-
with pytest.raises(ValueError, match=match):
214-
interval.resolve()
213+
with pytest.warns(DeprecationWarning, match="VariableInterval.resolve"):
214+
with pytest.raises(ValueError, match=match):
215+
interval.resolve()

0 commit comments

Comments
 (0)