Skip to content

Commit 256ed4b

Browse files
committed
Give every mapped expansion a region and store its index as region_index
Mapped tasks were the one kind of dynamic work still outside the region model: their instances sat in the sentinel region and were told apart only by map_index. A loop has to be able to contain an expansion, one per pass, and that only works if the expansion owns a region of its own. The coordinate column is renamed in place so large deployments pay for metadata changes, not a table rewrite. No table is rebuilt because SQLite's rebuild would cascade-delete the rows that reference task_instance.id. legacy_task_data_owner, xcom_v1 and rtif_v1 keep map_index because they record the coordinates their rows had before regions existed. The ORM keeps map_index as an alias: Jinja log-filename templates and provider code still read ti.map_index from ORM rows, and removing it needs its own audit. Mapping keeps its existing behaviour. A task's region is born with its placeholder, re-expansion grows or shrinks it in place, and a map never forks. Promoting the placeholder to index 0 stays in place too. Attempts retired before the promotion stay at index -1: they own their XCom, rendered fields and log files by UUID, and moving them could collide with retired attempts already at index 0. A pre-region placeholder never ran, so it has no history to keep; it moves into a freshly minted region and is promoted like any other, instead of being retired as a phantom failed try. Pre-region expansions stay in the sentinel region and keep revising in place, so the upgrade does not rewrite existing rows. Callers that address a task instance by public coordinates now find a task's own region as well as the sentinel. The DagRun wait endpoint reads current producers only and rejects loop results, because which pass's result to return would be ambiguous.
1 parent 2fd9c2a commit 256ed4b

89 files changed

Lines changed: 4284 additions & 697 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

‎airflow-core/docs/core-concepts/task-and-asset-state-store.rst‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -38,7 +38,7 @@ Task and Asset state store provide two key/value stores to persist data like a j
3838
- Default lifetime
3939
- Primary use case
4040
* - **Task state store**
41-
- A single task Instance (``dag_id`` + ``run_id`` + ``task_id`` + ``region_id`` + ``map_index``)
41+
- A single task Instance (``dag_id`` + ``run_id`` + ``task_id`` + ``region_id`` + ``region_index``)
4242
- Configurable retention; cleared on task success when ``clear_on_success = True``
4343
- Survive retries, track in-flight jobs, checkpoint progress within a run, resume progress from checkpoint set by a past run
4444
* - **Asset state store**

‎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-
| ``54a27b6f9d01`` (head) | ``e7c2a91bd540`` | ``3.4.0`` | Add dynamic region storage. |
42+
| ``7f8c9a2d410e`` (head) | ``54a27b6f9d01`` | ``3.4.0`` | Rename the stored map index to region_index and add the |
43+
| | | | dynamic region slot key. |
44+
+-------------------------+------------------+-------------------+--------------------------------------------------------------+
45+
| ``54a27b6f9d01`` | ``e7c2a91bd540`` | ``3.4.0`` | Add dynamic region storage. |
4346
+-------------------------+------------------+-------------------+--------------------------------------------------------------+
4447
| ``e7c2a91bd540`` | ``90e4d18ccadf`` | ``3.4.0`` | Unify task attempt ownership without rewriting legacy XCom |
4548
| | | | data. |

‎airflow-core/src/airflow/api_fastapi/common/parameters/misc.py‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -245,7 +245,7 @@ def _optional_boolean(value: bool | None) -> bool | None:
245245
FilterParam[int | None],
246246
Depends(
247247
filter_param_factory(
248-
TaskInstance.map_index,
248+
TaskInstance.region_index,
249249
int | None,
250250
filter_name="map_index",
251251
)

‎airflow-core/src/airflow/api_fastapi/common/parameters/task_instance.py‎

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -237,7 +237,11 @@ def _transform_ti_states(states: list[str] | None) -> list[TaskInstanceState | N
237237
FilterParam[list[int]],
238238
Depends(
239239
filter_param_factory(
240-
TaskInstance.map_index, list[int], FilterOptionEnum.ANY_EQUAL, default_factory=list
240+
TaskInstance.region_index,
241+
list[int],
242+
FilterOptionEnum.ANY_EQUAL,
243+
filter_name="map_index",
244+
default_factory=list,
241245
)
242246
),
243247
]

‎airflow-core/src/airflow/api_fastapi/core_api/openapi/v2-rest-api-generated.yaml‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3083,6 +3083,12 @@ paths:
30833083
schema:
30843084
$ref: '#/components/schemas/HTTPExceptionResponse'
30853085
description: Not Found
3086+
'409':
3087+
content:
3088+
application/json:
3089+
schema:
3090+
$ref: '#/components/schemas/HTTPExceptionResponse'
3091+
description: Conflict
30863092
'422':
30873093
description: Validation Error
30883094
content:

‎airflow-core/src/airflow/api_fastapi/core_api/routes/public/dag_run.py‎

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -863,7 +863,7 @@ def trigger_dag_run(
863863
summary="Experimental: Wait for a dag run to complete, and return task results if requested.",
864864
description="🚧 This is an experimental endpoint and may change or be removed without notice.Successful response are streamed as newline-delimited JSON (NDJSON). Each line is a JSON object representing the Dag run state.",
865865
responses={
866-
**create_openapi_http_exception_doc([status.HTTP_404_NOT_FOUND]),
866+
**create_openapi_http_exception_doc([status.HTTP_404_NOT_FOUND, status.HTTP_409_CONFLICT]),
867867
status.HTTP_200_OK: {
868868
"description": "Successful Response",
869869
"content": {
@@ -886,6 +886,7 @@ def trigger_dag_run(
886886
def wait_dag_run_until_finished(
887887
dag_id: str,
888888
dag_run_id: str,
889+
dag_bag: DagBagDep,
889890
session: SessionDep,
890891
user: GetUserDep,
891892
interval: Annotated[float, Query(gt=0.0, description="Seconds to wait between dag run state checks")],
@@ -917,7 +918,8 @@ def wait_dag_run_until_finished(
917918
"User is not authorized to read XCom data for this Dag",
918919
)
919920
result_task_ids = [] # Explicitly not returning any XCom results.
920-
if not session.scalar(select(1).where(DagRun.dag_id == dag_id, DagRun.run_id == dag_run_id)):
921+
dag_run = session.scalar(select(DagRun).where(DagRun.dag_id == dag_id, DagRun.run_id == dag_run_id))
922+
if dag_run is None:
921923
raise HTTPException(
922924
status.HTTP_404_NOT_FOUND,
923925
f"The DagRun with dag_id: `{dag_id}` and run_id: `{dag_run_id}` was not found",
@@ -928,6 +930,7 @@ def wait_dag_run_until_finished(
928930
interval=interval,
929931
result_task_ids=result_task_ids,
930932
)
933+
waiter.validate_result_selection(dag_run, dag_bag, session=session)
931934
return StreamingResponse(waiter.wait())
932935

933936

‎airflow-core/src/airflow/api_fastapi/core_api/routes/public/task_state_store.py‎

Lines changed: 20 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,8 @@
4242
from airflow.state.metastore import _get_db_backend
4343

4444
if TYPE_CHECKING:
45+
from uuid import UUID
46+
4547
from sqlalchemy.orm import Session
4648

4749
task_state_store_router = AirflowRouter(
@@ -56,16 +58,17 @@ def _require_task_instance(
5658
task_id: str,
5759
map_index: int | None,
5860
session: Session,
59-
) -> None:
60-
"""Raise 404 unless the task instance exists. ``map_index=None`` matches any map index."""
61-
statement = select(TI.task_id).where(
61+
) -> UUID:
62+
"""Return the region of the addressed task instance, raising 404 when it does not exist."""
63+
statement = select(TI.region_id).where(
6264
TI.dag_id == dag_id,
6365
TI.run_id == dag_run_id,
6466
TI.task_id == task_id,
6567
)
6668
if map_index is not None:
67-
statement = statement.where(TI.map_index == map_index)
68-
if session.scalar(statement.limit(1)) is None:
69+
statement = statement.where(TI.region_index == map_index)
70+
region_id = session.scalar(statement.order_by(TI.region_id).limit(1))
71+
if region_id is None:
6972
addressed_by = "all_map_indices=True" if map_index is None else f"map_index={map_index}"
7073
raise HTTPException(
7174
status_code=status.HTTP_404_NOT_FOUND,
@@ -74,6 +77,7 @@ def _require_task_instance(
7477
f"task_id={task_id!r}, {addressed_by}"
7578
),
7679
)
80+
return region_id
7781

7882

7983
def _resolve_scope(
@@ -91,8 +95,10 @@ def _resolve_scope(
9195

9296
def _validate_scope(scope: TaskScopeDep, session: SessionDep) -> TaskScope:
9397
"""Resolve the scope, 404ing when the task instance it addresses does not exist."""
94-
_require_task_instance(scope.dag_id, scope.run_id, scope.task_id, scope.map_index, session)
95-
return scope
98+
region_id = _require_task_instance(scope.dag_id, scope.run_id, scope.task_id, scope.map_index, session)
99+
return TaskScope(
100+
scope.dag_id, scope.run_id, scope.task_id, region_index=scope.region_index, region_id=region_id
101+
)
96102

97103

98104
ValidatedTaskScopeDep = Annotated[TaskScope, Depends(_validate_scope)]
@@ -109,10 +115,12 @@ def _validate_clear_scope(
109115
``all_map_indices`` addresses the task across every index, so it is validated against any
110116
instance -- an expanded mapped task has no ``map_index=-1`` instance to check.
111117
"""
112-
_require_task_instance(
118+
region_id = _require_task_instance(
113119
scope.dag_id, scope.run_id, scope.task_id, None if all_map_indices else scope.map_index, session
114120
)
115-
return scope
121+
return TaskScope(
122+
scope.dag_id, scope.run_id, scope.task_id, region_index=scope.region_index, region_id=region_id
123+
)
116124

117125

118126
ValidatedClearTaskScopeDep = Annotated[TaskScope, Depends(_validate_clear_scope)]
@@ -161,7 +169,7 @@ def list_task_state_store(
161169
TaskStateStoreModel.dag_id == scope.dag_id,
162170
TaskStateStoreModel.run_id == scope.run_id,
163171
TaskStateStoreModel.task_id == scope.task_id,
164-
TaskStateStoreModel.map_index == scope.map_index,
172+
TaskStateStoreModel.region_index == scope.region_index,
165173
)
166174
.order_by(TaskStateStoreModel.key.asc())
167175
)
@@ -204,7 +212,7 @@ def get_task_state_store(
204212
TaskStateStoreModel.dag_id == scope.dag_id,
205213
TaskStateStoreModel.run_id == scope.run_id,
206214
TaskStateStoreModel.task_id == scope.task_id,
207-
TaskStateStoreModel.map_index == scope.map_index,
215+
TaskStateStoreModel.region_index == scope.region_index,
208216
TaskStateStoreModel.key == key,
209217
)
210218
).one_or_none()
@@ -256,7 +264,7 @@ def patch_task_state_store(
256264
TaskStateStoreModel.dag_id == scope.dag_id,
257265
TaskStateStoreModel.run_id == scope.run_id,
258266
TaskStateStoreModel.task_id == scope.task_id,
259-
TaskStateStoreModel.map_index == scope.map_index,
267+
TaskStateStoreModel.region_index == scope.region_index,
260268
TaskStateStoreModel.key == key,
261269
)
262270
).one_or_none()

‎airflow-core/src/airflow/api_fastapi/core_api/routes/ui/gantt.py‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -73,7 +73,7 @@ def get_gantt_data(
7373
).where(
7474
TaskInstance.dag_id == dag_id,
7575
TaskInstance.run_id == run_id,
76-
TaskInstance.map_index == -1,
76+
TaskInstance.region_index == -1,
7777
or_(TaskInstance.state != TaskInstanceState.UP_FOR_RETRY, TaskInstance.state.is_(None)),
7878
)
7979

‎airflow-core/src/airflow/api_fastapi/core_api/services/public/dag_run.py‎

Lines changed: 78 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,6 @@
2020
import asyncio
2121
import itertools
2222
import json
23-
import operator
2423
from typing import TYPE_CHECKING, Any
2524

2625
import attrs
@@ -61,13 +60,15 @@
6160
from airflow.listeners.listener import get_listener_manager
6261
from airflow.models.dagrun import DagRun, clear_partition_runs
6362
from airflow.models.renderedtifields import load_legacy_rendered_fields
63+
from airflow.models.task_coordinates import enclosing_loop, public_map_index_expression
6464
from airflow.models.taskinstance import TaskInstance
6565
from airflow.models.xcom import XCOM_RETURN_KEY, XComModel, xcom_entity
6666
from airflow.utils.session import create_session_async
6767
from airflow.utils.state import State, TaskInstanceState
6868

6969
if TYPE_CHECKING:
70-
from collections.abc import AsyncGenerator, Iterator
70+
from collections.abc import AsyncGenerator
71+
from uuid import UUID
7172

7273
from airflow.serialization.definitions.dag import SerializedDAG
7374

@@ -256,44 +257,90 @@ class DagRunWaiter:
256257
interval: float
257258
result_task_ids: list[str] | None
258259

260+
def validate_result_selection(self, dag_run: DagRun, dag_bag: DagBagDep, *, session: Session) -> None:
261+
if self.result_task_ids == []:
262+
return
263+
query = select(TaskInstance.task_id, TaskInstance.dag_version_id).where(
264+
TaskInstance.dag_id == self.dag_id, TaskInstance.run_id == self.run_id
265+
)
266+
if self.result_task_ids is not None:
267+
query = query.where(TaskInstance.task_id.in_(self.result_task_ids))
268+
versions: dict[UUID | None, set[str]] = {}
269+
for task_id, version in session.execute(query.distinct()):
270+
versions.setdefault(version or dag_run.created_dag_version_id, set()).add(task_id)
271+
current_task_ids = {task_id for task_ids in versions.values() for task_id in task_ids}
272+
created_dag = (
273+
dag_bag.get_dag(dag_run.created_dag_version_id, session=session)
274+
if dag_run.created_dag_version_id is not None
275+
else None
276+
)
277+
if created_dag is not None:
278+
requested = (
279+
set(self.result_task_ids) if self.result_task_ids is not None else set(created_dag.task_ids)
280+
)
281+
versions.setdefault(dag_run.created_dag_version_id, set()).update(requested - current_task_ids)
282+
for version, task_ids in versions.items():
283+
if version is None:
284+
continue
285+
dag = (
286+
created_dag
287+
if version == dag_run.created_dag_version_id
288+
else dag_bag.get_dag(version, session=session)
289+
)
290+
if dag is None:
291+
continue
292+
for task_id in task_ids:
293+
if not dag.has_task(task_id):
294+
continue
295+
task = dag.get_task(task_id)
296+
if self.result_task_ids is None and not task.returns_dag_result:
297+
continue
298+
if enclosing_loop(task) is not None:
299+
raise HTTPException(
300+
status.HTTP_409_CONFLICT,
301+
f"Cannot collect loop task {task_id!r} as a DagRun result; "
302+
"publish the result through a task outside the loop.",
303+
)
304+
259305
async def _get_dag_run(self) -> DagRun:
260306
async with create_session_async() as session:
261307
return await session.scalar(select(DagRun).filter_by(dag_id=self.dag_id, run_id=self.run_id))
262308

263309
async def _serialize_xcoms(self) -> dict[str, Any]:
310+
xcom_query = XComModel.get_many(
311+
run_id=self.run_id,
312+
key=XCOM_RETURN_KEY,
313+
dag_ids=self.dag_id,
314+
task_ids=self.result_task_ids,
315+
region_id=None,
316+
)
317+
entity = xcom_entity(xcom_query)
264318
if self.result_task_ids is None: # Return dag-author-specified results.
265-
xcom_read = XComModel.get_many(
266-
run_id=self.run_id,
267-
key=XCOM_RETURN_KEY,
268-
dag_ids=self.dag_id,
269-
)
270-
entity = xcom_entity(xcom_read)
271-
xcom_query = xcom_read.where(entity.dag_result.is_(True))
272-
else: # Explicitly API user-specified results.
273-
xcom_read = XComModel.get_many(
274-
run_id=self.run_id,
275-
key=XCOM_RETURN_KEY,
276-
task_ids=self.result_task_ids,
277-
dag_ids=self.dag_id,
278-
)
279-
xcom_query = xcom_read
280-
entity = xcom_entity(xcom_read)
319+
xcom_query = xcom_query.where(entity.dag_result.is_(True))
281320
# XComModel.get_many() orders XCom by timestamp. Reset this to make
282321
# mapped task results stable since execution order is not guaranteed.
283-
xcom_query = xcom_query.order_by(None).order_by(entity.task_id, entity.map_index)
322+
xcom_query = (
323+
xcom_query.add_columns(public_map_index_expression(entity))
324+
.order_by(None)
325+
.order_by(entity.task_id, entity.region_index)
326+
)
284327
async with create_session_async() as session:
285-
xcom_results = (await session.scalars(xcom_query)).all()
286-
287-
def _group_xcoms(g: Iterator[XComModel | tuple[XComModel]]) -> Any:
288-
entries = [row[0] if isinstance(row, tuple) else row for row in g]
289-
if len(entries) == 1 and entries[0].map_index < 0: # Unpack non-mapped task xcom.
290-
return entries[0].value
291-
return [entry.value for entry in entries] # Task is mapped; return all xcoms in a list.
292-
293-
return {
294-
task_id: _group_xcoms(g)
295-
for task_id, g in itertools.groupby(xcom_results, key=operator.attrgetter("task_id"))
296-
}
328+
xcom_results = (await session.execute(xcom_query)).all()
329+
330+
results = {}
331+
for task_id, group in itertools.groupby(xcom_results, key=lambda row: row[0].task_id):
332+
entries = list(group)
333+
if len({entry.region_id for entry, _ in entries}) > 1 or (
334+
len(entries) > 1 and any(map_index < 0 for _, map_index in entries)
335+
):
336+
raise ValueError(
337+
f"Ambiguous current producers for task {task_id!r} in DAG run {self.run_id!r}"
338+
)
339+
if len(entries) == 1 and entries[0][1] < 0:
340+
results[task_id] = entries[0][0].value
341+
else:
342+
results[task_id] = [entry.value for entry, _ in entries]
343+
return results
297344

298345
async def _serialize_response(self, dag_run: DagRun) -> str:
299346
resp = {"state": dag_run.state}

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

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -60,7 +60,7 @@ def _fetch_ti_writer_fields(token: TIToken, session: SessionDep) -> _TIWriterFie
6060
TaskInstance.dag_id,
6161
TaskInstance.run_id,
6262
TaskInstance.task_id,
63-
TaskInstance.map_index,
63+
TaskInstance.region_index.label("map_index"),
6464
).where(TaskInstance.id == token.id)
6565
).one_or_none()
6666
if row is None:

0 commit comments

Comments
 (0)