This is an automated email from the ASF dual-hosted git repository.
o-nikolas 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 00ce83c6c44 Fix deadline never firing after non-deadline Dag edit
(#68734)
00ce83c6c44 is described below
commit 00ce83c6c443f75020ef8764718e585e0048efe9
Author: Ehab Abdalla <[email protected]>
AuthorDate: Wed Jul 15 15:52:41 2026 -0400
Fix deadline never firing after non-deadline Dag edit (#68734)
The structural issue was that cls(dag) was constructed before knowing which
branch (skip / UPDATE / INSERT) we'd take. By deferring construction past the
branch decision, each branch can prepare dag.data correctly before the
constructor runs — eliminating the need for post-hoc patching of internal
attributes.
---
airflow-core/src/airflow/models/serialized_dag.py | 18 ++--
.../tests/unit/models/test_serialized_dag.py | 113 +++++++++++++++++++++
2 files changed, 125 insertions(+), 6 deletions(-)
diff --git a/airflow-core/src/airflow/models/serialized_dag.py
b/airflow-core/src/airflow/models/serialized_dag.py
index fd773e8ed4f..fbff450a993 100644
--- a/airflow-core/src/airflow/models/serialized_dag.py
+++ b/airflow-core/src/airflow/models/serialized_dag.py
@@ -643,6 +643,7 @@ class SerializedDagModel(Base):
dag_version = _prefetched.dag_version
name_updated = False
+ reused_deadline_data: dict[str, dict] | None = None
if dag.data.get("dag", {}).get("deadline"):
# Try to reuse existing deadline UUIDs if the deadline definitions
haven't changed.
# This preserves the hash and avoids unnecessary
SerializedDagModel recreations.
@@ -674,6 +675,7 @@ class SerializedDagModel(Base):
)
name_updated = bool(name_updates)
dag.data["dag"]["deadline"] = existing_deadline_uuids
+ reused_deadline_data = deadline_uuid_mapping
deadline_uuid_mapping = {}
else:
# At least one deadline has changed, generate new UUIDs
and update the hash.
@@ -684,13 +686,9 @@ class SerializedDagModel(Base):
else:
deadline_uuid_mapping = {}
- new_serialized_dag = cls(dag)
+ new_dag_hash = cls.hash(dag.data)
- if (
- serialized_dag_hash == new_serialized_dag.dag_hash
- and dag_version
- and dag_version.bundle_name == bundle_name
- ):
+ if serialized_dag_hash == new_dag_hash and dag_version and
dag_version.bundle_name == bundle_name:
# Serialized content is unchanged, so we don't create a new
DagVersion.
# But if the bundle advanced, refresh the latest version's pointer
in place — tasks resolve
# their code from ``ti.dag_version.bundle_version`` at run time,
so a stale
@@ -728,6 +726,7 @@ class SerializedDagModel(Base):
# This is for dynamic DAGs that the hashes changes often. We
should update
# the serialized dag, the dag_version and the dag_code instead of
a new version
# if the dag_version is not associated with any task instances
+ new_serialized_dag = cls(dag)
# Use direct UPDATE to avoid loading the full serialized DAG
result = session.execute(
@@ -773,8 +772,15 @@ class SerializedDagModel(Base):
session=session,
)
log.debug("Writing Serialized DAG: %s to the DB", dag.dag_id)
+
+ if reused_deadline_data:
+ deadline_uuid_mapping = {str(uuid6.uuid7()): data for data in
reused_deadline_data.values()}
+ dag.data["dag"]["deadline"] = list(deadline_uuid_mapping.keys())
+
+ new_serialized_dag = cls(dag)
new_serialized_dag.dag_version = dagv
session.add(new_serialized_dag)
+
cls._create_deadline_alert_records(new_serialized_dag,
deadline_uuid_mapping)
log.debug("DAG: %s written to the DB", dag.dag_id)
DagCode.write_code(dagv, dag.fileloc, session=session)
diff --git a/airflow-core/tests/unit/models/test_serialized_dag.py
b/airflow-core/tests/unit/models/test_serialized_dag.py
index 03a4159eb2b..fcb49805b4c 100644
--- a/airflow-core/tests/unit/models/test_serialized_dag.py
+++ b/airflow-core/tests/unit/models/test_serialized_dag.py
@@ -1042,3 +1042,116 @@ class TestSerializedDagModel:
# The name must have been updated in the DB.
assert updated_alert.name == "updated name"
+
+ def test_non_deadline_edit_creates_deadline_alert_for_new_serdag(self,
testing_dag_bundle, session):
+ """Non-deadline Dag edit must create deadline_alert rows for the new
serialized_dag row."""
+ dag_id = "test_deadline_orphan"
+
+ 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)
+
+ # Create a dagrun so the existing dag_version has task instances,
+ # forcing write_dag into the INSERT branch (new serialized_dag row).
+ scheduler_dag.create_dagrun(
+ run_id="test1",
+ run_after=DEFAULT_DATE,
+ state=DagRunState.QUEUED,
+ logical_date=DEFAULT_DATE,
+ data_interval=(DEFAULT_DATE, DEFAULT_DATE),
+ triggered_by=DagRunTriggeredByType.TEST,
+ run_type=DagRunType.MANUAL,
+ )
+ session.commit()
+
+ orig_serdag = session.scalar(select(SDM).where(SDM.dag_id ==
dag_id).order_by(SDM.created_at.desc()))
+ orig_alert = session.scalar(select(DAM).where(DAM.serialized_dag_id ==
orig_serdag.id))
+ assert orig_alert is not None
+
+ # Add a second task (non-deadline change) — deadline definition is
untouched.
+ EmptyOperator(task_id="task2", dag=dag)
+ SDM.write_dag(LazyDeserializedDAG.from_dag(dag),
bundle_name="testing", session=session)
+ session.commit()
+
+ new_serdag = session.scalar(select(SDM).where(SDM.dag_id ==
dag_id).order_by(SDM.created_at.desc()))
+ assert new_serdag.id != orig_serdag.id, "A new serialized_dag row
should have been created"
+
+ new_alert = session.scalar(select(DAM).where(DAM.serialized_dag_id ==
new_serdag.id))
+ assert new_alert is not None, (
+ f"serialized_dag {new_serdag.id} has deadline UUIDs in JSON but no
deadline_alert row links to it"
+ )
+
+ # The old alert should still be intact for the old serialized_dag.
+ old_alert = session.scalar(select(DAM).where(DAM.serialized_dag_id ==
orig_serdag.id))
+ assert old_alert is not None
+
+ def test_non_deadline_edit_preserves_alert_in_update_branch(self,
testing_dag_bundle, session):
+ """UPDATE branch (no task instances): existing deadline_alert stays
linked after non-deadline edit."""
+ dag_id = "test_deadline_update_branch"
+
+ 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()
+
+ # No dagrun created — dag_version has no task instances, so write_dag
+ # takes the UPDATE branch (in-place update of the existing
serialized_dag row).
+ orig_serdag = session.scalar(select(SDM).where(SDM.dag_id == dag_id))
+ orig_alert = session.scalar(select(DAM).where(DAM.serialized_dag_id ==
orig_serdag.id))
+ assert orig_alert is not None
+
+ EmptyOperator(task_id="task2", dag=dag)
+ SDM.write_dag(LazyDeserializedDAG.from_dag(dag),
bundle_name="testing", session=session)
+ session.commit()
+
+ # Same row was updated in place — only one serialized_dag should exist.
+ serdag_count =
session.scalar(select(func.count()).select_from(SDM).where(SDM.dag_id ==
dag_id))
+ assert serdag_count == 1
+
+ updated_serdag = session.scalar(select(SDM).where(SDM.dag_id ==
dag_id))
+ alert = session.scalar(select(DAM).where(DAM.serialized_dag_id ==
updated_serdag.id))
+ assert alert is not None, "deadline_alert must still be linked after
UPDATE-branch re-serialization"
+
+ def test_deadline_reuse_skips_write_when_hash_matches(self,
testing_dag_bundle, session):
+ """When nothing changed, write_dag short-circuits and existing alerts
remain valid."""
+ dag_id = "test_deadline_noop"
+
+ 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()
+
+ orig_serdag = session.scalar(select(SDM).where(SDM.dag_id == dag_id))
+ orig_alert = session.scalar(select(DAM).where(DAM.serialized_dag_id ==
orig_serdag.id))
+ assert orig_alert is not None
+
+ # Re-serialize the exact same Dag — nothing changed.
+ did_write = SDM.write_dag(LazyDeserializedDAG.from_dag(dag),
bundle_name="testing", session=session)
+ session.commit()
+
+ assert did_write is False
+
+ alert = session.scalar(select(DAM).where(DAM.serialized_dag_id ==
orig_serdag.id))
+ assert alert is not None
+ assert alert.id == orig_alert.id