Skip to content

Commit 420e3c3

Browse files
committed
Add durable task instance launch records
Persist immutable executor launch tokens independently of mutable or deleted TaskInstance rows so that expected stale executor launches return a typed 409 stale_executor_launch instead of an opaque 404. Refs #69760
1 parent 204ba78 commit 420e3c3

27 files changed

Lines changed: 970 additions & 19 deletions

File tree

airflow-core/docs/migrations-ref.rst

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -39,7 +39,10 @@ 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-
| ``7a98f1b7dbd3`` (head) | ``c4e7a1f9b2d0`` | ``3.4.0`` | Add index on asset_event (asset_id, partition_key). |
42+
| ``3c5f8e9a1d2b`` (head) | ``7a98f1b7dbd3`` | ``3.4.0`` | Add task_instance_launch table for durable executor launch |
43+
| | | | records. |
44+
+-------------------------+------------------+-------------------+--------------------------------------------------------------+
45+
| ``7a98f1b7dbd3`` | ``c4e7a1f9b2d0`` | ``3.4.0`` | Add index on asset_event (asset_id, partition_key). |
4346
+-------------------------+------------------+-------------------+--------------------------------------------------------------+
4447
| ``c4e7a1f9b2d0`` | ``436dc127462c`` | ``3.4.0`` | Add index on asset.uri. |
4548
+-------------------------+------------------+-------------------+--------------------------------------------------------------+

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

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,8 @@ class TIEnterRunningPayload(StrictBaseModel):
6464
"""Process Identifier on `hostname`"""
6565
start_date: UtcDateTime
6666
"""When the task started executing"""
67+
external_executor_id: str | None = None
68+
"""Executor token for durable launch validation"""
6769

6870

6971
# Create an enum to give a nice name in the generated datamodels
@@ -286,6 +288,8 @@ class TaskInstance(BaseModel):
286288
map_index: int = -1
287289
hostname: str | None = None
288290
context_carrier: dict | None = None
291+
external_executor_id: str | None = None
292+
"""Executor token for durable launch validation"""
289293
# The supervisor routes tasks to a coordinator by queue. The default keeps
290294
# hand-built instances (tests, dry runs) valid; the executor workload
291295
# always sends the real value.

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

Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -83,6 +83,7 @@
8383
from airflow.models.dagrun import DagRun as DR
8484
from airflow.models.hitl import HITLDetail
8585
from airflow.models.log import Log
86+
from airflow.models.task_instance_launch import TERMINAL_STATES, TaskInstanceLaunch
8687
from airflow.models.taskinstance import TaskInstance as TI, _stop_remaining_tasks
8788
from airflow.models.taskinstancehistory import TaskInstanceHistory as TIH
8889
from airflow.models.taskreschedule import TaskReschedule
@@ -163,6 +164,7 @@ def ti_run(
163164
TI.hostname,
164165
TI.unixname,
165166
TI.pid,
167+
TI.external_executor_id,
166168
# This selects the raw JSON value, bypassing the deserialization -- we want that to happen on the
167169
# client
168170
column("next_kwargs", JSON),
@@ -179,6 +181,25 @@ def ti_run(
179181
ti = session.execute(old).one()
180182
log.debug("Retrieved task instance details", state=ti.state, dag_id=ti.dag_id, task_id=ti.task_id)
181183
except NoResultFound:
184+
# TI not found - check if we have a token to validate against launch records
185+
payload_token = ti_run_payload.external_executor_id
186+
if payload_token:
187+
# Check if this token has a known launch record
188+
launch = TaskInstanceLaunch.get_by_token(payload_token, session=session)
189+
if launch and launch.state in TERMINAL_STATES:
190+
# Token is known but in a terminal state (consumed or superseded)
191+
log.warning(
192+
"Stale executor token for missing task instance", token=payload_token, state=launch.state
193+
)
194+
raise HTTPException(
195+
status_code=status.HTTP_409_CONFLICT,
196+
detail={
197+
"reason": "stale_executor_launch",
198+
"message": "Executor token is stale or has been superseded",
199+
},
200+
)
201+
# Token is unknown/null - ambiguous, could be upgrade scenario or stale executor
202+
# Let the 404 be returned
182203
log.error("Task Instance not found")
183204
raise HTTPException(
184205
status_code=status.HTTP_404_NOT_FOUND,
@@ -188,6 +209,39 @@ def ti_run(
188209
},
189210
)
190211

212+
# Validate launch token if provided (launch record presence drives enforcement)
213+
# If TI has a DB token and that token has a launch record, enforce strict matching
214+
payload_token = ti_run_payload.external_executor_id
215+
db_token = ti.external_executor_id
216+
217+
if db_token:
218+
# Check if this DB token has an active/terminal launch record
219+
launch_record = TaskInstanceLaunch.get_by_token(db_token, session=session)
220+
if launch_record:
221+
# Launch record exists - enforce strict matching
222+
if payload_token != db_token:
223+
log.error(
224+
"External executor ID mismatch for known launch record",
225+
payload_token=payload_token,
226+
db_token=db_token,
227+
launch_state=launch_record.state,
228+
)
229+
raise HTTPException(
230+
status_code=status.HTTP_409_CONFLICT,
231+
detail={
232+
"reason": "stale_executor_launch",
233+
"message": "Executor token does not match known launch record",
234+
},
235+
)
236+
elif payload_token and payload_token != db_token:
237+
# No launch record exists, preserve rolling compatibility
238+
# Only enforce if both have tokens and they differ
239+
log.debug(
240+
"Token mismatch but no launch record (rolling upgrade scenario)",
241+
payload_token=payload_token,
242+
db_token=db_token,
243+
)
244+
191245
# We exclude_unset to avoid updating fields that are not set in the payload
192246
data = ti_run_payload.model_dump(exclude_unset=True)
193247

@@ -258,6 +312,32 @@ def ti_run(
258312
result = session.execute(query)
259313
log.info("Task instance state updated", rows_affected=getattr(result, "rowcount", 0))
260314

315+
# Mark the launch record as consumed if we have a token and the transition to RUNNING succeeded.
316+
# Only mark on successful transitions from QUEUED/RESTARTING (not on duplicate RUNNING requests).
317+
if payload_token and previous_state in (TaskInstanceState.QUEUED, TaskInstanceState.RESTARTING):
318+
marked = TaskInstanceLaunch.mark_consumed(
319+
token=payload_token,
320+
session=session,
321+
)
322+
if marked:
323+
log.debug("Marked launch token as consumed", token=payload_token)
324+
else:
325+
# If mark_consumed returns False, the record was already consumed or missing
326+
# Only allow on duplicate RUNNING request (same host/user/pid), otherwise it's a conflict
327+
if previous_state == TaskInstanceState.RUNNING:
328+
log.debug("Launch token already consumed (duplicate request)", token=payload_token)
329+
else:
330+
# Roll back the update since mark_consumed failed and this isn't a duplicate
331+
session.rollback()
332+
log.error("Failed to mark launch token as consumed", token=payload_token)
333+
raise HTTPException(
334+
status_code=status.HTTP_409_CONFLICT,
335+
detail={
336+
"reason": "stale_executor_launch",
337+
"message": "Launch record not found or already consumed",
338+
},
339+
)
340+
261341
dr = (
262342
session.scalars(
263343
select(DR)

airflow-core/src/airflow/api_fastapi/execution_api/versions/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,7 @@
4747
AddPartitionDateField,
4848
AddRetryPolicyFields,
4949
AddTaskAndAssetStateStoreEndpoints,
50+
AddTaskInstanceExternalExecutorIdField,
5051
AddTaskInstanceQueueField,
5152
AddTeamNameField,
5253
AddVariableKeysEndpoint,
@@ -59,6 +60,7 @@
5960
AddVariableKeysEndpoint,
6061
AddConnectionTestEndpoint,
6162
AddAwaitingInputStatePayload,
63+
AddTaskInstanceExternalExecutorIdField,
6264
AddTaskInstanceQueueField,
6365
AddRetryPolicyFields,
6466
AddTeamNameField,

airflow-core/src/airflow/api_fastapi/execution_api/versions/v2026_06_30.py

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@
2929
DagRun,
3030
TaskInstance,
3131
TIAwaitingInputStatePayload,
32+
TIEnterRunningPayload,
3233
TIRetryStatePayload,
3334
TIRunContext,
3435
)
@@ -61,6 +62,17 @@ class AddTaskInstanceQueueField(VersionChange):
6162
instructions_to_migrate_to_previous_version = (schema(TaskInstance).field("queue").didnt_exist,)
6263

6364

65+
class AddTaskInstanceExternalExecutorIdField(VersionChange):
66+
"""Add the `external_executor_id` field to task instance launch payloads."""
67+
68+
description = __doc__
69+
70+
instructions_to_migrate_to_previous_version = (
71+
schema(TaskInstance).field("external_executor_id").didnt_exist,
72+
schema(TIEnterRunningPayload).field("external_executor_id").didnt_exist,
73+
)
74+
75+
6476
class AddAwaitingInputStatePayload(VersionChange):
6577
"""Add the awaiting_input task instance state transition payload (Human-in-the-loop, no trigger)."""
6678

airflow-core/src/airflow/executors/workloads/task.py

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -45,7 +45,12 @@ class TaskInstanceDTO(TaskInstance):
4545
pool_slots: int
4646
priority_weight: int
4747

48-
external_executor_id: str | None = Field(default=None, exclude=True)
48+
# NOT excluded: the durable launch token must reach the worker so it can echo
49+
# it back on POST /run for stale-executor fencing. Executors that pre-assign
50+
# external_executor_id (Celery, Kubernetes) rely on this surviving serialization
51+
# into the workload JSON that is shipped to the worker/pod. executor_config stays
52+
# excluded — it is a scheduler-only concern the worker never reads.
53+
external_executor_id: str | None = Field(default=None)
4954
executor_config: dict | None = Field(default=None, exclude=True)
5055

5156
# TODO: Task-SDK: Can we replace TaskInstanceKey with just the uuid across the codebase?

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

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -105,6 +105,7 @@
105105
from airflow.models.dagwarning import DagWarning, DagWarningType
106106
from airflow.models.pool import normalize_pool_name_for_stats
107107
from airflow.models.serialized_dag import SerializedDagModel
108+
from airflow.models.task_instance_launch import TaskInstanceLaunch, TaskInstanceLaunchState
108109
from airflow.models.taskinstance import TaskInstance
109110
from airflow.models.taskinstancekey import TaskInstanceKey
110111
from airflow.models.team import Team
@@ -1099,6 +1100,30 @@ def _executable_task_instances_to_queued(self, max_tis: int, session: Session) -
10991100
else:
11001101
session.execute(queued_update)
11011102

1103+
# Bulk insert TaskInstanceLaunch records for active launches with non-null tokens.
1104+
# This happens after external_executor_ids have been synced into executable_tis in-memory.
1105+
# The DB-stored external_executor_id values will be read back in the next query if needed.
1106+
launch_records = []
1107+
for ti in executable_tis:
1108+
token = ti.external_executor_id
1109+
if token:
1110+
launch_records.append(
1111+
TaskInstanceLaunch(
1112+
token=token,
1113+
task_instance_id=str(ti.id),
1114+
dag_id=ti.dag_id,
1115+
task_id=ti.task_id,
1116+
run_id=ti.run_id,
1117+
map_index=ti.map_index or -1,
1118+
try_number=ti.try_number,
1119+
executor=ti.executor or "default",
1120+
state=TaskInstanceLaunchState.ACTIVE,
1121+
)
1122+
)
1123+
if launch_records:
1124+
session.add_all(launch_records)
1125+
session.flush()
1126+
11021127
for ti in executable_tis:
11031128
ti.emit_state_change_metric(TaskInstanceState.QUEUED)
11041129

@@ -3212,6 +3237,15 @@ def _reschedule_stuck_task(self, ti: TaskInstance, session: Session):
32123237
filter_for_tis = TI.filter_for_tis([ti])
32133238
if filter_for_tis is None:
32143239
return
3240+
3241+
# Supersede any active launch record before rescheduling, so the old token
3242+
# cannot be reused if the old executor manages to come back.
3243+
if ti.external_executor_id:
3244+
TaskInstanceLaunch.mark_superseded(
3245+
token=ti.external_executor_id,
3246+
session=session,
3247+
)
3248+
32153249
session.execute(
32163250
update(TI)
32173251
.where(filter_for_tis)
@@ -3220,6 +3254,7 @@ def _reschedule_stuck_task(self, ti: TaskInstance, session: Session):
32203254
queued_dttm=None,
32213255
queued_by_job_id=None,
32223256
scheduled_dttm=timezone.utcnow(),
3257+
external_executor_id=None, # Clear so next queue cycle assigns fresh token
32233258
)
32243259
.execution_options(synchronize_session=False)
32253260
)
@@ -3428,6 +3463,13 @@ def adopt_or_reset_orphaned_tasks(self, *, session: Session = NEW_SESSION) -> in
34283463
reset_tis_message = []
34293464
for ti in to_reset:
34303465
reset_tis_message.append(repr(ti))
3466+
# Mark the current launch token as superseded before resetting the TI
3467+
# so the old executor cannot re-adopt this task if it comes back
3468+
if ti.external_executor_id:
3469+
TaskInstanceLaunch.mark_superseded(
3470+
token=ti.external_executor_id,
3471+
session=session,
3472+
)
34313473
# If we reset a TI, it will be eligible to be scheduled again.
34323474
# This can cause the scheduler to increase the try_number on the TI.
34333475
# Record the current try to TaskInstanceHistory first so users have an audit trail for
Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
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 task_instance_launch table for durable executor launch records.
21+
22+
Revision ID: 3c5f8e9a1d2b
23+
Revises: 7a98f1b7dbd3
24+
Create Date: 2026-05-10 00:00:00.000000
25+
26+
"""
27+
28+
from __future__ import annotations
29+
30+
import sqlalchemy as sa
31+
from alembic import op
32+
33+
from airflow.utils.sqlalchemy import UtcDateTime
34+
35+
# revision identifiers, used by Alembic.
36+
revision = "3c5f8e9a1d2b"
37+
down_revision = "7a98f1b7dbd3"
38+
branch_labels = None
39+
depends_on = None
40+
airflow_version = "3.4.0"
41+
42+
43+
def upgrade():
44+
"""Create task_instance_launch table for durable executor token tracking."""
45+
op.create_table(
46+
"task_instance_launch",
47+
sa.Column("token", sa.String(256), nullable=False),
48+
sa.Column("task_instance_id", sa.String(250), nullable=False),
49+
sa.Column("dag_id", sa.String(250), nullable=False),
50+
sa.Column("task_id", sa.String(250), nullable=False),
51+
sa.Column("run_id", sa.String(250), nullable=False),
52+
sa.Column("map_index", sa.Integer(), nullable=False, server_default="-1"),
53+
sa.Column("try_number", sa.Integer(), nullable=False),
54+
sa.Column("executor", sa.String(256), nullable=False),
55+
sa.Column("state", sa.String(20), nullable=False, server_default="active"),
56+
sa.Column("created_at", UtcDateTime(timezone=True), nullable=False),
57+
sa.Column("updated_at", UtcDateTime(timezone=True), nullable=False),
58+
sa.Column("consumed_at", UtcDateTime(timezone=True), nullable=True),
59+
sa.Column("superseded_at", UtcDateTime(timezone=True), nullable=True),
60+
sa.PrimaryKeyConstraint("token", name="pk_task_instance_launch_token"),
61+
sa.CheckConstraint(
62+
"state IN ('active', 'consumed', 'superseded')",
63+
name="ck_task_instance_launch_state_enum",
64+
),
65+
)
66+
op.create_index(
67+
"idx_task_instance_launch_task_instance_id",
68+
"task_instance_launch",
69+
["task_instance_id"],
70+
)
71+
op.create_index(
72+
"idx_task_instance_launch_state_updated",
73+
"task_instance_launch",
74+
["state", "updated_at"],
75+
)
76+
77+
78+
def downgrade():
79+
"""Drop task_instance_launch table."""
80+
op.drop_table("task_instance_launch")

airflow-core/src/airflow/models/__init__.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,7 @@
4646
"SkipMixin",
4747
"TaskInstance",
4848
"TaskInstanceHistory",
49+
"TaskInstanceLaunch",
4950
"TaskReschedule",
5051
"Trigger",
5152
"Variable",
@@ -121,6 +122,7 @@ def __getattr__(name):
121122
"RenderedTaskInstanceFields": "airflow.models.renderedtifields",
122123
"SkipMixin": "airflow.sdk.bases.skipmixin",
123124
"TaskInstance": "airflow.models.taskinstance",
125+
"TaskInstanceLaunch": "airflow.models.task_instance_launch",
124126
"TaskReschedule": "airflow.models.taskreschedule",
125127
"Team": "airflow.models.team",
126128
"Trigger": "airflow.models.trigger",
@@ -145,6 +147,7 @@ def __getattr__(name):
145147
from airflow.models.log import Log
146148
from airflow.models.pool import Pool
147149
from airflow.models.renderedtifields import RenderedTaskInstanceFields
150+
from airflow.models.task_instance_launch import TaskInstanceLaunch
148151
from airflow.models.taskinstance import TaskInstance, clear_task_instances
149152
from airflow.models.taskinstancehistory import TaskInstanceHistory
150153
from airflow.models.taskreschedule import TaskReschedule

0 commit comments

Comments
 (0)