Skip to content

Commit c24242e

Browse files
authored
Load the serialized Dag in ti_run only for stub tasks (#75795)
* Load the serialized Dag in ti_run only for stub tasks Starting any task deserialized its whole Dag so the route could find out whether the task was a stub and read its arg_bindings. Denormalize the serialized is_stub flag onto task_instance, which ti_run already fetches and locks, and load the Dag only when it is set. execution_timeout is resolved from the same definition, so it is now sent for a lang-SDK task only; #75387 has a Python task send its own to the supervisor. * Hold SQLite foreign keys off while the downgrade rebuilds Dropping a column rebuilds the table on SQLite, unlike adding one, so the downgrade copies task_instance rows while other tables still point at it. * Pin the bulk-insert path and reword the is_stub comments create_dagrun refreshes every row from its task right after the bulk write, so the existing test passed even when insert_mapping dropped the key. Assert the mapping itself instead, and record what a row keeps when it moves to a new Dag version without being refreshed. * Mark a Go or Java TriggerDagRun task a stub, as TypeScript does * Inline the table name in the is_stub migration ---------
1 parent b432002 commit c24242e

18 files changed

Lines changed: 214 additions & 23 deletions

File tree

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

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -41,7 +41,9 @@ Here's the list of all the Database Migrations that are executed via when you ru
4141
+=========================+==================+===================+==============================================================+
4242
| ``38c8df3fcb9c`` (head) | ``c71065a1b14f`` | ``3.5.0`` | Add index on dag_run (dag_id, run_after). |
4343
+-------------------------+------------------+-------------------+--------------------------------------------------------------+
44-
| ``c71065a1b14f`` | ``ee86eed19e24`` | ``3.5.0`` | Add session_id and registration_id to Job. |
44+
| ``c71065a1b14f`` | ``b4f1a7d2c903`` | ``3.5.0`` | Add session_id and registration_id to Job. |
45+
+-------------------------+------------------+-------------------+--------------------------------------------------------------+
46+
| ``b4f1a7d2c903`` | ``ee86eed19e24`` | ``3.4.0`` | Add is_stub to task_instance. |
4547
+-------------------------+------------------+-------------------+--------------------------------------------------------------+
4648
| ``ee86eed19e24`` | ``9cffe90fa0c0`` | ``3.4.0`` | Add pending_partition_key to asset_partition_dag_run and |
4749
| | | | enforce single pending row per key. |

‎airflow-core/src/airflow/api_fastapi/execution_api/datamodels/taskinstance.py‎

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -558,9 +558,10 @@ class TIRunContext(BaseModel):
558558

559559
execution_timeout: float | None = None
560560
"""
561-
The task's ``execution_timeout`` in seconds, taken from its serialized Dag.
561+
The ``execution_timeout`` in seconds of a task a language SDK runs, from its serialized Dag.
562562
563-
The supervisor enforces the timeout with this value. ``None`` when no timeout is set.
563+
The supervisor enforces the timeout with this value. A Python task runs its own timer in the task
564+
runner instead, and gets no value here. ``None`` when no timeout is set.
564565
"""
565566

566567
multi_team: bool = False

‎airflow-core/src/airflow/api_fastapi/execution_api/routes/task_instances.py‎

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -197,6 +197,7 @@ def ti_run(
197197
TI.unixname,
198198
TI.pid,
199199
TI.dag_version_id,
200+
TI.is_stub,
200201
# This selects the raw JSON value, bypassing the deserialization -- we want that to happen on the
201202
# client
202203
column("next_kwargs", JSON),
@@ -382,7 +383,10 @@ def ti_run(
382383
if first_task_reschedule_start_date is not None:
383384
context.first_task_reschedule_start_date = first_task_reschedule_start_date
384385

385-
serialized_task = get_task(dag_bag, ti, session=session)
386+
# Avoid loading the serialized Dag on the normal Python task path: only a lang-SDK
387+
# (foreign-runtime) task needs its definition here, and ti.is_stub carries the row's own copy
388+
# of the serialized flag.
389+
serialized_task = get_task(dag_bag, ti, session=session) if ti.is_stub else None
386390

387391
# Only set for lang-SDK (foreign-runtime) tasks with a captured TaskFlow arg
388392
# spec; the route excludes unset fields, keeping regular responses lean.
@@ -404,6 +408,9 @@ def ti_run(
404408
},
405409
)
406410

411+
# Resolved here for a lang-SDK task only, since a regular task no longer loads its
412+
# definition. #75387 has a Python task send its own execution_timeout to the supervisor,
413+
# which is where the value for that kind of task belongs.
407414
if (execution_timeout := get_execution_timeout(serialized_task)) is not None:
408415
context.execution_timeout = execution_timeout
409416

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
#
2+
# Licensed to the Apache Software Foundation (ASF) under one
3+
# or more contributor license agreements. See the NOTICE file
4+
# distributed with this work for additional information
5+
# regarding copyright ownership. The ASF licenses this file
6+
# to you under the Apache License, Version 2.0 (the
7+
# "License"); you may not use this file except in compliance
8+
# with the License. You may obtain a copy of the License at
9+
#
10+
# http://www.apache.org/licenses/LICENSE-2.0
11+
#
12+
# Unless required by applicable law or agreed to in writing,
13+
# software distributed under the License is distributed on an
14+
# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
15+
# KIND, either express or implied. See the License for the
16+
# specific language governing permissions and limitations
17+
# under the License.
18+
19+
"""
20+
Add is_stub to task_instance.
21+
22+
Revision ID: b4f1a7d2c903
23+
Revises: ee86eed19e24
24+
Create Date: 2026-10-27 09:12:44.317602
25+
26+
"""
27+
28+
from __future__ import annotations
29+
30+
import sqlalchemy as sa
31+
from alembic import op
32+
33+
from airflow.migrations.utils import sqlite_rebuilds
34+
35+
# revision identifiers, used by Alembic.
36+
revision = "b4f1a7d2c903"
37+
down_revision = "ee86eed19e24"
38+
branch_labels = None
39+
depends_on = None
40+
airflow_version = "3.4.0"
41+
42+
43+
def upgrade():
44+
"""Add is_stub to task_instance."""
45+
with op.batch_alter_table("task_instance") as batch_op:
46+
batch_op.add_column(sa.Column("is_stub", sa.Boolean(), nullable=True))
47+
48+
49+
def downgrade():
50+
"""Remove is_stub from task_instance."""
51+
# Dropping a column rebuilds the table on SQLite, unlike adding one, so the foreign keys of
52+
# everything that points at task_instance have to be held off while the rows are copied.
53+
with sqlite_rebuilds(op), op.batch_alter_table("task_instance") as batch_op:
54+
batch_op.drop_column("is_stub")

airflow-core/src/airflow/migrations/versions/0149_3_5_0_add_session_id_to_job.py renamed to airflow-core/src/airflow/migrations/versions/0150_3_5_0_add_session_id_to_job.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@
2020
Add session_id and registration_id to Job.
2121
2222
Revision ID: c71065a1b14f
23-
Revises: ee86eed19e24
23+
Revises: b4f1a7d2c903
2424
Create Date: 2026-10-05 08:40:20.000000
2525
2626
"""
@@ -33,7 +33,7 @@
3333
from airflow.migrations.utils import disable_sqlite_fkeys
3434

3535
revision = "c71065a1b14f"
36-
down_revision = "ee86eed19e24"
36+
down_revision = "b4f1a7d2c903"
3737
branch_labels = None
3838
depends_on = None
3939
airflow_version = "3.5.0"

airflow-core/src/airflow/migrations/versions/0150_3_5_0_add_dag_run_dag_id_run_after_index.py renamed to airflow-core/src/airflow/migrations/versions/0151_3_5_0_add_dag_run_dag_id_run_after_index.py

File renamed without changes.

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

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1141,6 +1141,11 @@ class TaskInstance(Base, LoggingMixin, BaseWorkload):
11411141
priority_weight: Mapped[int | None] = mapped_column(Integer, nullable=True)
11421142
operator: Mapped[str | None] = mapped_column(String(1000), nullable=True)
11431143
custom_operator_name: Mapped[str | None] = mapped_column(String(1000), nullable=True)
1144+
# Denormalized from the task so ti_run can tell a stub (foreign-runtime) task from a regular one
1145+
# without loading the serialized Dag. Null on rows written before the column existed. A row moved
1146+
# to a new Dag version without being refreshed from its task keeps the value it was created with,
1147+
# which fails closed: a task that has become a stub loses its arg_bindings rather than running wrong.
1148+
is_stub: Mapped[bool | None] = mapped_column(Boolean, nullable=True)
11441149
queued_dttm: Mapped[datetime | None] = mapped_column(UtcDateTime, nullable=True)
11451150
scheduled_dttm: Mapped[datetime | None] = mapped_column(UtcDateTime, nullable=True)
11461151
queued_by_job_id: Mapped[int | None] = mapped_column(Integer, nullable=True)
@@ -1385,6 +1390,7 @@ def insert_mapping(
13851390
"executor_config": task.executor_config,
13861391
"operator": task.task_type,
13871392
"custom_operator_name": getattr(task, "operator_name", None),
1393+
"is_stub": task.is_stub,
13881394
"region_index": region_index,
13891395
"region_id": region_id,
13901396
"_task_display_property_value": task.task_display_name,
@@ -1581,6 +1587,7 @@ def refresh_from_task(
15811587
self.operator = task.task_type
15821588
op_name = getattr(task, "operator_name", None)
15831589
self.custom_operator_name = op_name if isinstance(op_name, str) else ""
1590+
self.is_stub = task.is_stub
15841591
# Re-apply cluster policy here so that task default do not overload previous data
15851592
task_instance_mutation_hook(self, dag_run=dag_run)
15861593

‎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": "ee86eed19e24",
120+
"3.4.0": "b4f1a7d2c903",
121121
"3.5.0": "38c8df3fcb9c",
122122
}
123123

‎airflow-core/tests/unit/api_fastapi/execution_api/versions/head/test_task_instances.py‎

Lines changed: 46 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1109,6 +1109,49 @@ def transform(country: str, extracted: dict, limit: int = 10): ...
11091109
assert response.status_code == 200
11101110
assert "arg_bindings" not in response.json()
11111111

1112+
def test_ti_run_does_not_load_the_dag_for_a_regular_task(self, client, dag_maker, mocker):
1113+
"""The row rules the task out, so starting it reads no serialized Dag -- the point of the column."""
1114+
with dag_maker("test_regular_task_arg_bindings", serialized=True):
1115+
1116+
@task
1117+
def transform(country: str): ...
1118+
1119+
transform("uk")
1120+
1121+
dr = dag_maker.create_dagrun()
1122+
(ti,) = dr.get_task_instances()
1123+
assert ti.is_stub is False
1124+
ti.set_state(State.QUEUED)
1125+
dag_maker.session.flush()
1126+
get_dag = mocker.patch.object(DBDagBag, "get_dag", autospec=True)
1127+
1128+
response = client.patch(f"/execution/task-instances/{ti.id}/run", json=self.RUN_PAYLOAD)
1129+
1130+
assert response.status_code == 200
1131+
assert "arg_bindings" not in response.json()
1132+
get_dag.assert_not_called()
1133+
1134+
def test_ti_run_treats_an_unset_is_stub_as_a_regular_task(self, client, dag_maker):
1135+
"""A row written before the column existed carries null and keeps its pre-upgrade behavior."""
1136+
with dag_maker("test_unset_is_stub", serialized=True):
1137+
1138+
@task.stub
1139+
def transform(country: str): ...
1140+
1141+
transform("uk")
1142+
1143+
dr = dag_maker.create_dagrun()
1144+
(ti,) = dr.get_task_instances()
1145+
ti.set_state(State.QUEUED)
1146+
dag_maker.session.get(TaskInstance, ti.id).is_stub = None
1147+
dag_maker.session.commit()
1148+
assert dag_maker.session.scalar(select(TaskInstance.is_stub).where(TaskInstance.id == ti.id)) is None
1149+
1150+
response = client.patch(f"/execution/task-instances/{ti.id}/run", json=self.RUN_PAYLOAD)
1151+
1152+
assert response.status_code == 200
1153+
assert "arg_bindings" not in response.json()
1154+
11121155
@mock.patch(
11131156
"airflow.api_fastapi.execution_api.routes.task_instances.get_arg_bindings",
11141157
autospec=True,
@@ -1160,7 +1203,9 @@ def transform(country: str): ...
11601203
pytest.param(
11611204
task.stub(execution_timeout=timedelta(seconds=-30)), 0.0, id="stub-negative-timeout"
11621205
),
1163-
pytest.param(task(execution_timeout=timedelta(seconds=10)), 10.0, id="python-task"),
1206+
# A Python task's timeout stays with the task runner, which sends it to the supervisor,
1207+
# so the server resolves one only for a task whose definition it loads anyway.
1208+
pytest.param(task(execution_timeout=timedelta(seconds=10)), None, id="python-task"),
11641209
],
11651210
)
11661211
def test_ti_run_returns_execution_timeout(self, client, dag_maker, decorator, expected):

‎airflow-core/tests/unit/dag_processing/lang_sdk_fixtures/go_native.json‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1621,6 +1621,7 @@
16211621
"ratio": 0.5,
16221622
"rows": 2
16231623
},
1624+
"is_stub": true,
16241625
"logical_date": "2026-09-30 00:00:00+00:00",
16251626
"skip_when_already_exists": true,
16261627
"task_id": "trigger_all",
@@ -1649,6 +1650,7 @@
16491650
"Triggered DAG": "_link_TriggerDagRunLink"
16501651
},
16511652
"_task_module": "airflow.providers.standard.operators.trigger_dagrun",
1653+
"is_stub": true,
16521654
"logical_date": "NOTSET",
16531655
"skip_when_already_exists": false,
16541656
"task_id": "trigger_default",

0 commit comments

Comments
 (0)