This is an automated email from the ASF dual-hosted git repository.
pierrejeambrun 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 f840a821380 Make deadline reads and serialization robust to
dynamic/malformed intervals (#68919)
f840a821380 is described below
commit f840a8213801731028a054e733801a6449f3297d
Author: Sean Ghaeli <[email protected]>
AuthorDate: Tue Jul 28 06:53:46 2026 -0700
Make deadline reads and serialization robust to dynamic/malformed intervals
(#68919)
* Make deadline reads and serialization robust to dynamic/malformed
intervals
Hardens the read and (de)serialization paths for deadline alerts so dynamic
(``VariableInterval``) and malformed stored data no longer break the UI/API.
- UI deadline-alert response: ``DeadlineAlert.interval`` is a JSON column
holding
the Airflow-serialized interval, not a plain number. Coerce it to seconds
for a
fixed ``timedelta`` and to ``None`` for a dynamic ``VariableInterval``
(resolved
later by the scheduler), instead of letting Pydantic 500 on the dict. The
``interval`` field becomes ``float | None``.
- Drop ``interval`` from the sortable columns of the deadline-alerts
endpoint:
ordering by a JSON column sorts by structure/text, not duration, so the
result
was arbitrary and misleading.
- Deserialization: route by the encoder-stamped ``__class_path`` ahead of
the
``reference_type`` name (a custom reference may share a class name with a
builtin), and raise a clear error for a reference with no importable
``__class_path`` instead of an opaque ``KeyError``.
- ``Deadline.__repr__`` / ``DeadlineAlert.__repr__`` no longer raise: guard
the
``dagrun`` relationship (the FK can be set while the relationship is None
after a
cascade delete) and handle the dict-shaped JSON interval. A ``__repr__``
must
never raise.
- ``prune_deadlines`` explicitly excludes deadlines already marked
``missed`` so a
missed deadline (whose callback is owned by the scheduler/triggerer) and
its
queued callback are never cascade-deleted.
Generated-by: Claude Code (Opus via Claude Code) on behalf of Sean Ghaeli
* Remove redundant comments in deadline sort/deserialize
The sort-key comment restated a constraint already covered by
test_order_by_interval_is_rejected; the __class_path comment restated the
ValueError message right below it.
Generated-by: Claude Code (Opus)
* Trim PR to the UI deadlineAlerts 500 fix only
Per review: keep only the interval coercion, order_by removal, and their
tests; move repr guards, decoder routing, and prune guard to a follow-up.
---------
Co-authored-by: Sean Ghaeli <[email protected]>
---
.../api_fastapi/core_api/datamodels/ui/deadline.py | 31 +++++++-
.../api_fastapi/core_api/openapi/_private_ui.yaml | 14 ++--
.../api_fastapi/core_api/routes/ui/deadlines.py | 2 +-
.../core_api/routes/ui/test_deadlines.py | 83 +++++++++++++++++++++-
4 files changed, 120 insertions(+), 10 deletions(-)
diff --git
a/airflow-core/src/airflow/api_fastapi/core_api/datamodels/ui/deadline.py
b/airflow-core/src/airflow/api_fastapi/core_api/datamodels/ui/deadline.py
index 6f9402f2360..9c09176fea4 100644
--- a/airflow-core/src/airflow/api_fastapi/core_api/datamodels/ui/deadline.py
+++ b/airflow-core/src/airflow/api_fastapi/core_api/datamodels/ui/deadline.py
@@ -19,9 +19,10 @@ from __future__ import annotations
from collections.abc import Iterable
from datetime import datetime
+from typing import Any
from uuid import UUID
-from pydantic import AliasPath, Field
+from pydantic import AliasPath, Field, field_validator
from airflow.api_fastapi.core_api.base import BaseModel
@@ -52,9 +53,35 @@ class DeadlineAlertResponse(BaseModel):
id: UUID
name: str | None = None
reference_type: str = Field(validation_alias=AliasPath("reference",
"reference_type"))
- interval: float = Field(description="Interval in seconds between deadline
evaluations.")
+ interval: float | None = Field(
+ default=None,
+ description=(
+ "Interval in seconds between the reference time and the deadline. "
+ "Null for a dynamic interval (e.g. a VariableInterval) whose value
is "
+ "only resolved at scheduler evaluation time."
+ ),
+ )
created_at: datetime
+ @field_validator("interval", mode="before")
+ @classmethod
+ def coerce_interval_to_seconds(cls, value: Any) -> float | None:
+ """
+ Coerce the stored ``interval`` into seconds.
+
+ ``interval`` is the Airflow-serialized SDK interval: a dict
+ ``{"__classname__": ..., "__data__": <seconds|dict>}``, not a plain
number.
+ Return the seconds for a fixed ``timedelta``, or ``None`` for a dynamic
+ interval (resolved later by the scheduler). Without this, Pydantic
500s on the dict.
+ """
+ if value is None or isinstance(value, (int, float)):
+ return value
+ if isinstance(value, dict):
+ data = value.get("__data__")
+ if isinstance(data, (int, float)):
+ return float(data)
+ return None
+
class DeadlineAlertCollectionResponse(BaseModel):
"""DeadlineAlert Collection serializer for responses."""
diff --git
a/airflow-core/src/airflow/api_fastapi/core_api/openapi/_private_ui.yaml
b/airflow-core/src/airflow/api_fastapi/core_api/openapi/_private_ui.yaml
index f16b2bbc9fe..75980eefb7b 100644
--- a/airflow-core/src/airflow/api_fastapi/core_api/openapi/_private_ui.yaml
+++ b/airflow-core/src/airflow/api_fastapi/core_api/openapi/_private_ui.yaml
@@ -937,13 +937,12 @@ paths:
type: string
description: 'Attributes to order by, multi criteria sort is
supported.
Prefix with `-` for descending order. Supported attributes: `id,
created_at,
- name, interval`'
+ name`'
default:
- created_at
title: Order By
description: 'Attributes to order by, multi criteria sort is
supported. Prefix
- with `-` for descending order. Supported attributes: `id,
created_at, name,
- interval`'
+ with `-` for descending order. Supported attributes: `id,
created_at, name`'
responses:
'200':
description: Successful Response
@@ -2849,9 +2848,13 @@ components:
type: string
title: Reference Type
interval:
- type: number
+ anyOf:
+ - type: number
+ - type: 'null'
title: Interval
- description: Interval in seconds between deadline evaluations.
+ description: Interval in seconds between the reference time and the
deadline.
+ Null for a dynamic interval (e.g. a VariableInterval) whose value
is only
+ resolved at scheduler evaluation time.
created_at:
type: string
format: date-time
@@ -2860,7 +2863,6 @@ components:
required:
- id
- reference_type
- - interval
- created_at
title: DeadlineAlertResponse
description: DeadlineAlert serializer for responses.
diff --git
a/airflow-core/src/airflow/api_fastapi/core_api/routes/ui/deadlines.py
b/airflow-core/src/airflow/api_fastapi/core_api/routes/ui/deadlines.py
index 06eda42ed89..2d080609c35 100644
--- a/airflow-core/src/airflow/api_fastapi/core_api/routes/ui/deadlines.py
+++ b/airflow-core/src/airflow/api_fastapi/core_api/routes/ui/deadlines.py
@@ -166,7 +166,7 @@ def get_dag_deadline_alerts(
SortParam,
Depends(
SortParam(
- ["id", "created_at", "name", "interval"],
+ ["id", "created_at", "name"],
DeadlineAlert,
).dynamic_depends(default="created_at")
),
diff --git
a/airflow-core/tests/unit/api_fastapi/core_api/routes/ui/test_deadlines.py
b/airflow-core/tests/unit/api_fastapi/core_api/routes/ui/test_deadlines.py
index acadab3a6b1..6c83044e17c 100644
--- a/airflow-core/tests/unit/api_fastapi/core_api/routes/ui/test_deadlines.py
+++ b/airflow-core/tests/unit/api_fastapi/core_api/routes/ui/test_deadlines.py
@@ -17,6 +17,8 @@
from __future__ import annotations
+from datetime import timedelta
+
import pytest
from sqlalchemy import select
@@ -26,7 +28,7 @@ from airflow.models.deadline_alert import DeadlineAlert
from airflow.models.serialized_dag import SerializedDagModel
from airflow.providers.standard.operators.empty import EmptyOperator
from airflow.sdk.definitions.callback import AsyncCallback
-from airflow.sdk.definitions.deadline import DeadlineReference
+from airflow.sdk.definitions.deadline import DeadlineReference,
VariableInterval
from airflow.utils.state import DagRunState
from airflow.utils.types import DagRunTriggeredByType, DagRunType
@@ -507,6 +509,17 @@ class TestGetDagDeadlineAlerts:
response = test_client.get("/dags/nonexistent_dag/deadlineAlerts")
assert response.status_code == 404
+ @pytest.mark.parametrize("order_by", ["interval", "-interval"])
+ def test_order_by_interval_is_rejected(self, test_client, order_by):
+ """``interval`` is a serialized-JSON column
(timedelta/VariableInterval dict), so DB-level
+ ordering by it is meaningless (sorts by JSON structure, not duration).
It must NOT be an
+ allowed sort key — the endpoint should reject it with a 400 rather
than silently returning
+ an arbitrarily-ordered list.
+ """
+ response = test_client.get(f"/dags/{DAG_ID}/deadlineAlerts",
params={"order_by": order_by})
+ assert response.status_code == 400
+ assert "interval" in response.json()["detail"]
+
def test_should_response_401(self, unauthenticated_test_client):
response =
unauthenticated_test_client.get(f"/dags/{DAG_ID}/deadlineAlerts")
assert response.status_code == 401
@@ -514,3 +527,71 @@ class TestGetDagDeadlineAlerts:
def test_should_response_403(self, unauthorized_test_client):
response =
unauthorized_test_client.get(f"/dags/{DAG_ID}/deadlineAlerts")
assert response.status_code == 403
+
+
+class TestDeadlineAlertsIntervalSerialization:
+ """Regression tests for the ``interval`` column shape on the
deadlineAlerts endpoint.
+
+ ``DeadlineAlert.interval`` is a JSON column that, in production, holds the
+ Airflow-*serialized* interval — ``encode_deadline_alert`` stores
+ ``serialize(self.interval)``, not a plain number. A fixed ``timedelta``
becomes
+ ``{"__classname__": "datetime.timedelta", "__version__": 2, "__data__":
<seconds>}``
+ and a dynamic ``VariableInterval`` becomes
+ ``{"__classname__": ".../VariableInterval", "__data__": {"key": ...}}``.
+
+ The response model originally typed ``interval`` as a bare ``float``, so
Pydantic
+ raised on that dict and the endpoint returned 500 — which broke the
run-page
+ ``DeadlineStatus`` badge. The existing fixtures masked this by seeding a
bare float
+ (``interval=3600.0``), a value the real creation path never produces.
These tests
+ seed the realistic serialized forms.
+ """
+
+ @pytest.fixture
+ def dag_with_serialized_intervals(self, dag_maker, session):
+ from airflow.sdk.serde import serialize
+
+ dag_id = "dag_serialized_interval"
+ with dag_maker(dag_id, serialized=True, session=session):
+ EmptyOperator(task_id="task")
+ dag_maker.sync_dagbag_to_db()
+ session.commit()
+
+ serialized_dag =
session.scalar(select(SerializedDagModel).where(SerializedDagModel.dag_id ==
dag_id))
+ # Fixed interval: stored as the serialized timedelta dict, exactly as
+ # encode_deadline_alert would persist it.
+ session.add(
+ DeadlineAlert(
+ serialized_dag_id=serialized_dag.id,
+ name="fixed_interval_alert",
+
reference=DeadlineReference.DAGRUN_QUEUED_AT.serialize_reference(),
+ interval=serialize(timedelta(seconds=300)),
+ callback_def={"path": _CALLBACK_PATH},
+ )
+ )
+ # Dynamic interval: a VariableInterval serializes to a dict with no
fixed
+ # seconds — the value is only resolved at scheduler evaluation time.
+ session.add(
+ DeadlineAlert(
+ serialized_dag_id=serialized_dag.id,
+ name="dynamic_interval_alert",
+
reference=DeadlineReference.DAGRUN_QUEUED_AT.serialize_reference(),
+ interval=serialize(VariableInterval("deadline_seconds")),
+ callback_def={"path": _CALLBACK_PATH},
+ )
+ )
+ session.commit()
+ return dag_id
+
+ def test_serialized_timedelta_interval_does_not_500(self, test_client,
dag_with_serialized_intervals):
+ """A fixed timedelta interval is coerced to its seconds value (not a
500)."""
+ response =
test_client.get(f"/dags/{dag_with_serialized_intervals}/deadlineAlerts")
+ assert response.status_code == 200
+ alerts = {a["name"]: a for a in response.json()["deadline_alerts"]}
+ assert alerts["fixed_interval_alert"]["interval"] == 300.0
+
+ def test_dynamic_variable_interval_serializes_as_null(self, test_client,
dag_with_serialized_intervals):
+ """A dynamic VariableInterval has no fixed seconds, so interval is
null (not a 500)."""
+ response =
test_client.get(f"/dags/{dag_with_serialized_intervals}/deadlineAlerts")
+ assert response.status_code == 200
+ alerts = {a["name"]: a for a in response.json()["deadline_alerts"]}
+ assert alerts["dynamic_interval_alert"]["interval"] is None