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 eaf5fc19ce7  allow deadline alert UUID references in serialized Dag 
schema (#70148)
eaf5fc19ce7 is described below

commit eaf5fc19ce7f799993f9e18fe5090a5718487e73
Author: Jung-Hyun Andrew Kim <[email protected]>
AuthorDate: Mon Aug 17 15:33:42 2026 -0700

     allow deadline alert UUID references in serialized Dag schema (#70148)
    
    Co-authored-by: Kevin Yang <[email protected]>
---
 airflow-core/src/airflow/models/serialized_dag.py  |  6 ++
 airflow-core/src/airflow/serialization/schema.json |  7 ++
 .../tests/unit/models/test_serialized_dag.py       | 97 ++++++++++++++++++++++
 devel-common/src/tests_common/test_utils/dag.py    |  6 +-
 4 files changed, 115 insertions(+), 1 deletion(-)

diff --git a/airflow-core/src/airflow/models/serialized_dag.py 
b/airflow-core/src/airflow/models/serialized_dag.py
index 95e581d7fa9..355c99f5291 100644
--- a/airflow-core/src/airflow/models/serialized_dag.py
+++ b/airflow-core/src/airflow/models/serialized_dag.py
@@ -19,6 +19,7 @@
 
 from __future__ import annotations
 
+import copy
 import logging
 import zlib
 from collections.abc import Callable, Iterable, Iterator, Sequence
@@ -647,6 +648,11 @@ class SerializedDagModel(Base):
         name_updated = False
         reused_deadline_data: dict[str, dict] | None = None
         if dag.data.get("dag", {}).get("deadline"):
+            # The deadline handling below rewrites data["dag"]["deadline"] 
from a list of
+            # encoded dicts into a list of UUID references. Work on a copy so 
we never mutate
+            # the caller's LazyDeserializedDAG in place.
+
+            dag = dag.model_copy(update={"data": copy.deepcopy(dag.data)})
             # Try to reuse existing deadline UUIDs if the deadline definitions 
haven't changed.
             # This preserves the hash and avoids unnecessary 
SerializedDagModel recreations.
             existing_serialized_dag = session.scalar(
diff --git a/airflow-core/src/airflow/serialization/schema.json 
b/airflow-core/src/airflow/serialization/schema.json
index bbb78a8e618..fa149734b7b 100644
--- a/airflow-core/src/airflow/serialization/schema.json
+++ b/airflow-core/src/airflow/serialization/schema.json
@@ -214,6 +214,13 @@
                     "type": "array",
                     "items": { "$ref": "#/definitions/dict" }
                 },
+                {
+                    "$comment": "Once persisted, a Dag's deadline alerts live 
as rows in the deadline_alert table and the serialized Dag keeps only a list of 
UUID strings referencing them (see 
SerializedDagModel._generate_deadline_uuids). This branch lets the stored form 
validate at any lifecycle stage, not only before the dict->UUID rewrite.",
+                    "type": "array",
+                    "items": { "type": "string",
+                    "pattern": 
"^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$"
+                  }
+                },
                 { "type": "null" }
             ]
         },
diff --git a/airflow-core/tests/unit/models/test_serialized_dag.py 
b/airflow-core/tests/unit/models/test_serialized_dag.py
index abbf179a714..f3d720ddcc6 100644
--- a/airflow-core/tests/unit/models/test_serialized_dag.py
+++ b/airflow-core/tests/unit/models/test_serialized_dag.py
@@ -19,6 +19,7 @@
 
 from __future__ import annotations
 
+import copy
 import logging
 from datetime import timedelta
 from unittest import mock
@@ -1234,3 +1235,99 @@ class TestSerializedDagModel:
         alert = session.scalar(select(DAM).where(DAM.serialized_dag_id == 
orig_serdag.id))
         assert alert is not None
         assert alert.id == orig_alert.id
+
+    def test_write_dag_with_deadline_passes_schema_validation(self, 
testing_dag_bundle, session):
+        """The persisted serialized Dag for a deadline-bearing Dag must 
satisfy the JSON schema.
+
+        write_dag stores ``data["dag"]["deadline"]`` as a list of UUID strings 
referencing
+        deadline_alert rows, so the schema has to accept that persisted form 
and not only the
+        list-of-dicts form produced before the dict->UUID rewrite.
+        """
+        dag_id = "test_deadline_schema_valid"
+        dag = DAG(
+            dag_id=dag_id,
+            deadline=DeadlineAlert(
+                reference=DeadlineReference.DAGRUN_QUEUED_AT,
+                interval=timedelta(minutes=5),
+                callback=AsyncCallback(empty_callback_for_deadline),
+            ),
+        )
+        EmptyOperator(task_id="task1", dag=dag)
+        sync_dag_to_db(dag, session=session)
+        session.commit()
+
+        result = session.scalar(select(SDM).where(SDM.dag_id == dag_id))
+        persisted_deadline = result.data["dag"]["deadline"]
+        assert isinstance(persisted_deadline, list)
+        assert persisted_deadline
+        assert all(isinstance(ref, str) for ref in persisted_deadline)
+
+        # Must not raise: the stored UUID-reference form has to satisfy the 
serialized Dag schema.
+        DagSerialization.validate_schema(result.data)
+
+    def test_write_dag_does_not_mutate_caller_deadline_data(self, 
testing_dag_bundle, session):
+        """write_dag must not rewrite the caller's LazyDeserializedDAG 
deadline in place.
+
+        The dict->UUID replacement in ``_generate_deadline_uuids`` has to 
happen on a copy so a
+        LazyDeserializedDAG the caller still references keeps its original 
list-of-dicts deadline.
+        """
+        dag_id = "test_deadline_no_mutation"
+        dag = DAG(
+            dag_id=dag_id,
+            deadline=DeadlineAlert(
+                reference=DeadlineReference.DAGRUN_QUEUED_AT,
+                interval=timedelta(minutes=5),
+                callback=AsyncCallback(empty_callback_for_deadline),
+            ),
+        )
+        EmptyOperator(task_id="task1", dag=dag)
+        sync_dag_to_db(dag, session=session)
+        session.commit()
+
+        # Change the interval so write_dag regenerates UUIDs (the dict->UUID 
rewrite path)
+        # rather than reusing the existing ones.
+        dag.deadline = DeadlineAlert(
+            reference=DeadlineReference.DAGRUN_QUEUED_AT,
+            interval=timedelta(minutes=10),
+            callback=AsyncCallback(empty_callback_for_deadline),
+        )
+        lazy_dag = LazyDeserializedDAG.from_dag(dag)
+        original_deadline = copy.deepcopy(lazy_dag.data["dag"]["deadline"])
+        assert original_deadline
+        assert all(isinstance(item, dict) for item in original_deadline)
+
+        SDM.write_dag(lazy_dag, bundle_name="testing", session=session)
+        session.commit()
+
+        assert lazy_dag.data["dag"]["deadline"] == original_deadline
+
+    def test_sync_dag_to_db_returns_db_normalized_deadline_ids(self, 
testing_dag_bundle, session):
+        """sync_dag_to_db must return a SerializedDAG with the DB-normalized 
deadline UUIDs. Verify that the UUIDs returned by sync_dag_to_db match the 
persisted deadline_alert rows in the DB."""
+        dag_id = "test_sync_dag_to_db_deadline_ids"
+        dag = DAG(
+            dag_id=dag_id,
+            deadline=DeadlineAlert(
+                reference=DeadlineReference.DAGRUN_QUEUED_AT,
+                interval=timedelta(minutes=5),
+                callback=AsyncCallback(empty_callback_for_deadline),
+            ),
+        )
+        EmptyOperator(task_id="task1", dag=dag)
+
+        scheduler_dag = sync_dag_to_db(dag, session=session)
+        session.commit()
+
+        latest_serdag = session.scalar(
+            select(SDM).where(SDM.dag_id == 
dag_id).order_by(SDM.created_at.desc())
+        )
+        assert latest_serdag is not None
+
+        persisted_alerts = 
session.scalars(select(DAM).where(DAM.serialized_dag_id == 
latest_serdag.id)).all()
+
+        persisted_uuids = {str(alert.id) for alert in persisted_alerts}
+        returned_uuids = scheduler_dag.deadline or []
+
+        assert returned_uuids
+        assert all(isinstance(ref, str) for ref in returned_uuids)
+        assert len(returned_uuids) == len(set(returned_uuids))
+        assert set(returned_uuids) == persisted_uuids
diff --git a/devel-common/src/tests_common/test_utils/dag.py 
b/devel-common/src/tests_common/test_utils/dag.py
index 891176eb499..72b19f3dd32 100644
--- a/devel-common/src/tests_common/test_utils/dag.py
+++ b/devel-common/src/tests_common/test_utils/dag.py
@@ -73,7 +73,11 @@ def sync_dags_to_db(
         SerializedDagModel.write_dag(
             LazyDeserializedDAG(data=data), bundle_name, bundle_version, 
session=session
         )
-        return DagSerialization.from_dict(data)
+        session.flush()
+        serialized_dag = SerializedDagModel.get_dag(dag.dag_id, 
session=session)
+        if serialized_dag is None:
+            raise RuntimeError(f"Serialized DAG {dag.dag_id!r} was not found 
after writing to the database")
+        return serialized_dag
 
     SerializedDAG.bulk_write_to_db(bundle_name, bundle_version, dags, 
session=session)
     scheduler_dags = [_write_dag(dag) for dag in dags]

Reply via email to