Skip to content

Commit 514cb98

Browse files
committed
Store executor callback DagRun references in the database
1 parent 5428b3b commit 514cb98

11 files changed

Lines changed: 361 additions & 20 deletions

File tree

‎airflow-core/docs/migrations-ref.rst‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -39,7 +39,9 @@ Here's the list of all the Database Migrations that are executed via when you ru
3939
+-------------------------+------------------+-------------------+--------------------------------------------------------------+
4040
| Revision ID | Revises ID | Airflow Version | Description |
4141
+=========================+==================+===================+==============================================================+
42-
| ``e5a91c7f42b3`` (head) | ``ca8499dc1004`` | ``3.4.0`` | Add language column to dag_code. |
42+
| ``0f4c9a2b8e1d`` (head) | ``e5a91c7f42b3`` | ``3.4.0`` | Add dagrun_id foreign key to callback. |
43+
+-------------------------+------------------+-------------------+--------------------------------------------------------------+
44+
| ``e5a91c7f42b3`` | ``ca8499dc1004`` | ``3.4.0`` | Add language column to dag_code. |
4345
+-------------------------+------------------+-------------------+--------------------------------------------------------------+
4446
| ``ca8499dc1004`` | ``a61f0c9d2b47`` | ``3.4.0`` | Add source_reference to import_error. |
4547
+-------------------------+------------------+-------------------+--------------------------------------------------------------+

‎airflow-core/src/airflow/jobs/scheduler_job_runner.py‎

Lines changed: 9 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1331,6 +1331,7 @@ def _enqueue_executor_callbacks(self, session: Session) -> None:
13311331

13321332
pending_callbacks = session.scalars(
13331333
select(ExecutorCallback)
1334+
.options(selectinload(ExecutorCallback.dag_run))
13341335
.where(ExecutorCallback.type == CallbackType.EXECUTOR)
13351336
.where(ExecutorCallback.state == CallbackState.PENDING)
13361337
.order_by(ExecutorCallback.priority_weight.desc())
@@ -1350,25 +1351,19 @@ def _enqueue_executor_callbacks(self, session: Session) -> None:
13501351
# Can't happen since we queried ExecutorCallback, but satisfies mypy.
13511352
continue
13521353

1353-
# TODO: Add dagrun_id as a proper ORM foreign key on the callback table instead of storing in data dict.
1354-
# This would eliminate this reconstruction step. For now, all ExecutorCallbacks
1355-
# are expected to have dag_run_id set in their data dict (e.g., by Deadline.handle_miss).
1356-
if not isinstance(callback.data, dict) or "dag_run_id" not in callback.data:
1357-
self.log.error(
1358-
"ExecutorCallback %s is missing required 'dag_run_id' in data dict. "
1359-
"This indicates a bug in callback creation. Skipping callback.",
1360-
callback.id,
1354+
if callback.dagrun_id is None:
1355+
self.log.warning(
1356+
"Executor callback is missing dagrun_id.",
1357+
callback_id=callback.id,
13611358
)
13621359
continue
13631360

1364-
dag_run_id = callback.data["dag_run_id"]
1365-
dag_run = session.get(DagRun, dag_run_id)
1366-
1361+
dag_run = callback.dag_run
13671362
if dag_run is None:
13681363
self.log.warning(
1369-
"Could not find DagRun with id=%s for callback %s. DagRun may have been deleted.",
1370-
dag_run_id,
1371-
callback.id,
1364+
"Could not find DagRun for executor callback. DagRun may have been deleted.",
1365+
callback_id=callback.id,
1366+
dagrun_id=callback.dagrun_id,
13721367
)
13731368
continue
13741369

Lines changed: 165 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,165 @@
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+
"""
19+
Add dagrun_id foreign key to callback.
20+
21+
Revision ID: 0f4c9a2b8e1d
22+
Revises: e5a91c7f42b3
23+
Create Date: 2026-08-06 00:00:00.000000
24+
25+
"""
26+
27+
from __future__ import annotations
28+
29+
import json
30+
31+
import sqlalchemy as sa
32+
from alembic import context, op
33+
34+
from airflow.configuration import conf
35+
36+
# revision identifiers, used by Alembic.
37+
revision = "0f4c9a2b8e1d"
38+
down_revision = "e5a91c7f42b3"
39+
branch_labels = None
40+
depends_on = None
41+
airflow_version = "3.4.0"
42+
43+
44+
def _deserialize_data(data):
45+
if isinstance(data, str):
46+
try:
47+
data = json.loads(data)
48+
except json.JSONDecodeError:
49+
return {}
50+
return data.get("__var", data) if isinstance(data, dict) else {}
51+
52+
53+
def _extract_dagrun_id(data) -> int | None:
54+
dagrun_id = _deserialize_data(data).get("dag_run_id")
55+
if dagrun_id is None:
56+
return None
57+
try:
58+
return int(dagrun_id)
59+
except (TypeError, ValueError):
60+
return None
61+
62+
63+
def _backfill_dagrun_id_from_deadline(conn, batch_size: int) -> None:
64+
callback = sa.table(
65+
"callback",
66+
sa.column("id", sa.Uuid()),
67+
sa.column("type", sa.String(20)),
68+
sa.column("dagrun_id", sa.Integer()),
69+
)
70+
deadline = sa.table(
71+
"deadline",
72+
sa.column("callback_id", sa.Uuid()),
73+
sa.column("dagrun_id", sa.Integer()),
74+
)
75+
dag_run = sa.table("dag_run", sa.column("id", sa.Integer()))
76+
77+
last_id = None
78+
while True:
79+
query = (
80+
sa.select(callback.c.id, deadline.c.dagrun_id)
81+
.select_from(
82+
callback.join(deadline, callback.c.id == deadline.c.callback_id).join(
83+
dag_run, deadline.c.dagrun_id == dag_run.c.id
84+
)
85+
)
86+
.where(callback.c.type == "executor", callback.c.dagrun_id.is_(None))
87+
.order_by(callback.c.id)
88+
.limit(batch_size)
89+
)
90+
if last_id is not None:
91+
query = query.where(callback.c.id > last_id)
92+
93+
rows = conn.execute(query).fetchall()
94+
if not rows:
95+
return
96+
97+
for callback_id, dagrun_id in rows:
98+
conn.execute(callback.update().where(callback.c.id == callback_id).values(dagrun_id=dagrun_id))
99+
100+
last_id = rows[-1].id
101+
102+
103+
def _backfill_dagrun_id(conn, batch_size: int) -> None:
104+
callback = sa.table(
105+
"callback",
106+
sa.column("id", sa.Uuid()),
107+
sa.column("type", sa.String(20)),
108+
sa.column("data", sa.Text()),
109+
sa.column("dagrun_id", sa.Integer()),
110+
)
111+
dag_run = sa.table("dag_run", sa.column("id", sa.Integer()))
112+
113+
last_id = None
114+
while True:
115+
query = (
116+
sa.select(callback.c.id, callback.c.data)
117+
.where(callback.c.type == "executor", callback.c.dagrun_id.is_(None))
118+
.order_by(callback.c.id)
119+
.limit(batch_size)
120+
)
121+
if last_id is not None:
122+
query = query.where(callback.c.id > last_id)
123+
124+
rows = conn.execute(query).fetchall()
125+
if not rows:
126+
return
127+
128+
for callback_id, data in rows:
129+
dagrun_id = _extract_dagrun_id(data)
130+
if dagrun_id is None:
131+
continue
132+
133+
dagrun_exists = conn.execute(sa.select(dag_run.c.id).where(dag_run.c.id == dagrun_id)).first()
134+
if dagrun_exists:
135+
conn.execute(
136+
callback.update().where(callback.c.id == callback_id).values(dagrun_id=dagrun_id)
137+
)
138+
139+
last_id = rows[-1].id
140+
141+
142+
def upgrade():
143+
"""Add callback.dagrun_id and backfill it from legacy callback.data."""
144+
with op.batch_alter_table("callback") as batch_op:
145+
batch_op.add_column(sa.Column("dagrun_id", sa.Integer(), nullable=True))
146+
147+
if not context.is_offline_mode():
148+
conn = op.get_bind()
149+
batch_size = conf.getint("database", "migration_batch_size")
150+
_backfill_dagrun_id_from_deadline(conn, batch_size)
151+
_backfill_dagrun_id(conn, batch_size)
152+
153+
with op.batch_alter_table("callback") as batch_op:
154+
batch_op.create_index("callback_dagrun_id_idx", ["dagrun_id"], unique=False)
155+
batch_op.create_foreign_key(
156+
batch_op.f("callback_dagrun_id_fkey"), "dag_run", ["dagrun_id"], ["id"], ondelete="CASCADE"
157+
)
158+
159+
160+
def downgrade():
161+
"""Remove callback.dagrun_id."""
162+
with op.batch_alter_table("callback") as batch_op:
163+
batch_op.drop_constraint(batch_op.f("callback_dagrun_id_fkey"), type_="foreignkey")
164+
batch_op.drop_index("callback_dagrun_id_idx")
165+
batch_op.drop_column("dagrun_id")

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

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,7 @@
2626

2727
import structlog
2828
import uuid6
29-
from sqlalchemy import ForeignKey, Integer, String, Text, Uuid
29+
from sqlalchemy import ForeignKey, Index, Integer, String, Text, Uuid
3030
from sqlalchemy.orm import Mapped, mapped_column, relationship
3131

3232
# Re-exporting as _accepts_context for backward compatibility
@@ -143,10 +143,17 @@ class Callback(Base, BaseWorkload):
143143
# Creation time of the callback
144144
created_at: Mapped[datetime] = mapped_column(UtcDateTime, default=timezone.utcnow, nullable=False)
145145

146+
dagrun_id: Mapped[int | None] = mapped_column(
147+
Integer, ForeignKey("dag_run.id", ondelete="CASCADE"), nullable=True
148+
)
149+
dag_run = relationship("DagRun", back_populates="callbacks")
150+
146151
# Used for callbacks of type CallbackType.TRIGGERER
147152
trigger_id: Mapped[int | None] = mapped_column(Integer, ForeignKey("trigger.id"), nullable=True)
148153
trigger = relationship("Trigger", back_populates="callback", uselist=False)
149154

155+
__table_args__ = (Index("callback_dagrun_id_idx", dagrun_id, unique=False),)
156+
150157
def __init__(self, priority_weight: int = 1, prefix: str = "", **kwargs):
151158
"""
152159
Initialize a Callback. This is the base class so it shouldn't usually need to be initialized.

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

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -385,6 +385,13 @@ class DagRun(Base, LoggingMixin):
385385
cascade="all, delete, delete-orphan",
386386
)
387387

388+
callbacks = relationship(
389+
"Callback",
390+
back_populates="dag_run",
391+
uselist=True,
392+
passive_deletes=True,
393+
)
394+
388395
created_dag_version = relationship("DagVersion", uselist=False, passive_deletes=True)
389396
"""
390397
The dag version that was active when the dag run was created, if available.

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

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -265,9 +265,9 @@ def callback_data_with_context():
265265
elif isinstance(self.callback, ExecutorCallback):
266266
data = callback_data_with_context()
267267
data["deadline_id"] = str(self.id)
268-
data["dag_run_id"] = str(self.dagrun.id)
269268
data["dag_id"] = self.dagrun.dag_id
270269
self.callback.data = data
270+
self.callback.dagrun_id = self.dagrun.id
271271

272272
self.callback.state = CallbackState.PENDING
273273
session.add(self.callback)

‎airflow-core/src/airflow/utils/db.py‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -117,7 +117,7 @@ class MappedClassProtocol(Protocol):
117117
"3.1.8": "509b94a1042d",
118118
"3.2.0": "1d6611b6ab7c",
119119
"3.3.0": "d2f4e1b3c5a7",
120-
"3.4.0": "e5a91c7f42b3",
120+
"3.4.0": "0f4c9a2b8e1d",
121121
}
122122

123123
# Prefix used to identify tables holding data moved during migration.

‎airflow-core/tests/unit/jobs/test_scheduler_job.py‎

Lines changed: 39 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,7 @@
6464
from airflow.executors.executor_utils import ExecutorName
6565
from airflow.executors.local_executor import LocalExecutor
6666
from airflow.executors.workloads import WorkloadType
67+
from airflow.executors.workloads.callback import CallbackFetchMethod
6768
from airflow.jobs.job import Job, run_job
6869
from airflow.jobs.scheduler_job_runner import SCHEDULER_DAG_CACHE_SIZE, SchedulerJobRunner
6970
from airflow.listeners.listener import get_listener_manager
@@ -837,7 +838,7 @@ def create_callback_in_state(state: CallbackState):
837838
deadline_alert_id=None,
838839
).callback
839840
callback.state = state
840-
callback.data["dag_run_id"] = dag_run.id
841+
callback.dagrun_id = dag_run.id
841842
callback.data["dag_id"] = dag_run.dag_id
842843
return callback
843844

@@ -867,6 +868,43 @@ def create_callback_in_state(state: CallbackState):
867868
assert session.get(ExecutorCallback, queued_callback.id).state == CallbackState.QUEUED
868869
assert session.get(ExecutorCallback, running_callback.id).state == CallbackState.RUNNING
869870

871+
@pytest.mark.parametrize(
872+
("dagrun_id", "expected_event"),
873+
[
874+
(None, "Executor callback is missing dagrun_id."),
875+
(123, "Could not find DagRun for executor callback. DagRun may have been deleted."),
876+
],
877+
)
878+
def test_enqueue_executor_callbacks_logs_missing_dagrun_reference(
879+
self, dagrun_id, expected_event, caplog
880+
):
881+
def test_callback():
882+
pass
883+
884+
callback = ExecutorCallback(
885+
SyncCallback(test_callback),
886+
fetch_method=CallbackFetchMethod.IMPORT_PATH,
887+
dag_id="test_callback_missing_dagrun_reference",
888+
)
889+
callback.id = uuid4()
890+
callback.state = CallbackState.PENDING
891+
callback.dagrun_id = dagrun_id
892+
893+
session = MagicMock()
894+
session.scalars.return_value.all.return_value = [callback]
895+
896+
executor = MockExecutor()
897+
executor.queue_workload = MagicMock()
898+
scheduler_job = Job()
899+
self.job_runner = SchedulerJobRunner(job=scheduler_job, executors=[executor])
900+
self.job_runner._executor_to_workloads = MagicMock(return_value={executor: [callback]})
901+
902+
self.job_runner._enqueue_executor_callbacks(session)
903+
904+
assert {"event": expected_event, "callback_id": callback.id} in caplog
905+
executor.queue_workload.assert_not_called()
906+
assert callback.state == CallbackState.PENDING
907+
870908
@mock.patch("airflow.jobs.scheduler_job_runner.TaskCallbackRequest")
871909
@mock.patch("airflow._shared.observability.metrics.stats._get_backend")
872910
def test_process_executor_events_with_callback(

0 commit comments

Comments
 (0)