Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
43 commits
Select commit Hold shift + click to select a range
01d5518
AIP-104: Task Iteration
dabla Oct 8, 2026
4ec2b3c
Describe the behaviour in two iteration test docstrings
dabla Oct 8, 2026
1e420d2
Hand an item its own operator as the context's task
dabla Oct 8, 2026
264ab01
Say that item checkpoints keep results in the task state store
dabla Oct 8, 2026
7c4f49c
Document that items emitting to one asset share one event
dabla Oct 8, 2026
c51ff17
Refuse an unmappable upstream value as an iteration input
dabla Oct 8, 2026
9ed0ec6
Keep the retry policy's decision for the exception handed to the runner
dabla Oct 8, 2026
0e027c3
Run a sync item's enter and exit in its worker thread, and its kill o…
dabla Oct 8, 2026
6791145
Stop the iteration once the iterated task is killed
dabla Oct 8, 2026
f419f42
Keep the state of one iteration run in IterationState
dabla Oct 8, 2026
9f96f41
Keep default_args callbacks and execute hooks with the items
dabla Oct 8, 2026
2756e26
Kill in a thread of its own whenever on_kill() is called
dabla Oct 8, 2026
96b14f2
Kill the operator the parent's timeout strikes off the loop thread, a…
dabla Oct 8, 2026
bfe5db5
Send the parent's execution timeout to the supervisor once, not per s…
dabla Oct 8, 2026
ec4febb
Say that listeners fire once per iterated task instance, not per item
dabla Oct 9, 2026
9eadec1
Read an iteration's own XComs back under their indexed key
dabla Oct 9, 2026
24551fa
Classify an item's outcome in one method
dabla Oct 9, 2026
c1b0068
Keep what the indexed tasks ended with in IndexedTaskOutcomes
dabla Oct 9, 2026
c908268
Give the iteration's helper functions to the classes that own them
dabla Oct 9, 2026
63f4ee4
Refuse the backend clear on the indexed state store view and keep a k…
dabla Oct 9, 2026
f582e1e
Say iterate in the error a @task raises when iterate() gets no arguments
dabla Oct 9, 2026
c03f879
Let clone_context clone a context without inlet_events or dag_run
dabla Oct 9, 2026
c2c763a
Point async sub-tasks at XCom.aget_one for another iteration's value
dabla Oct 9, 2026
f811f22
Render an indexed task's templates against the same context keys it e…
dabla Oct 9, 2026
6d684eb
Say that an iteration returning None keeps its position and reads bac…
dabla Oct 9, 2026
797aa52
Join the SIGTERM kill thread before the iteration concludes
dabla Oct 9, 2026
4e722a4
Add the newsfragment for Task Iteration
dabla Oct 9, 2026
1e566d4
Report an indexed task's success or skip once its checkpoint is written
dabla Oct 9, 2026
d52ca9c
Keep the default decision for an undecided failure group instead of e…
dabla Oct 9, 2026
7d834e9
Do not start an indexed task instance pulled before the kill reached …
dabla Oct 9, 2026
6df446b
Write the context_for docstring in the imperative mood
dabla Oct 9, 2026
6b51e64
Name only the counts a kill left behind in the terminated task's message
dabla Oct 9, 2026
5f7a4db
Name the workers section the state store backend option lives in
dabla Oct 10, 2026
5019f84
Declare the delegate of a decorated expand input as a field so two in…
dabla Oct 10, 2026
730ef65
Refuse a literal that is no collection in iterate() as expand() does
dabla Oct 10, 2026
eb56027
Report an indexed task's success where a failing report leaves its ch…
dabla Oct 10, 2026
b4ada44
Give the copy prepared for execution an iteration state of its own
dabla Oct 10, 2026
bd177a3
Take no lock in on_kill, which the SIGTERM handler runs on the loop t…
dabla Oct 10, 2026
35a98d4
Treat an AirflowTaskTimeout a sync indexed task raises itself as that…
dabla Oct 10, 2026
e3aeb0c
Drop the per-indexed-task limit that could never fire before the pare…
dabla Oct 10, 2026
616fea8
Release the comms thread lock an asend cancelled while waiting for it…
dabla Oct 10, 2026
8a44bf7
Say when not to iterate an operator that finds its remote work by the…
dabla Oct 10, 2026
dcf8439
Add a POST /xcoms/{dag_id}/{run_id}/{task_id}/keys execution API endp…
dabla Sep 27, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .github/CODEOWNERS
Original file line number Diff line number Diff line change
Expand Up @@ -163,6 +163,8 @@ Dockerfile.ci @potiuk @ashb @gopidesupavan @amoghrajesh @jscheffl @bugraoz93 @ja
# AIP-72 - Task SDK
# Python SDK
/task-sdk/ @ashb @amoghrajesh
/task-sdk/src/airflow/sdk/execution_time/executor.py @ashb @amoghrajesh @dabla
/task-sdk/src/airflow/sdk/definitions/iterableoperator.py @ashb @amoghrajesh @dabla

# AIP-108 - Coordinators
/task-sdk/src/airflow/sdk/coordinators/ @jason810496 @uranusjr
Expand Down
1 change: 1 addition & 0 deletions airflow-core/newsfragments/62922.feature.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Task Iteration (AIP-104): ``.iterate()`` and ``.iterate_kwargs()`` on operators and ``@task`` run the mapped inputs inside one task instance, ``task_concurrency`` of them at a time, instead of one task instance per item as ``.expand()`` does. Each iteration's result is checkpointed in the task state store, so a retry resumes where the previous attempt stopped, and the task's return value is an ``XComIterable``, a lazy sequence downstream tasks index, iterate or ``.expand()`` over. The Task SDK docs page "Mapped tasks vs iterable tasks" says when to use which.
Original file line number Diff line number Diff line change
Expand Up @@ -40,3 +40,9 @@ class XComSequenceSliceResponse(RootModel):
"""XCom schema with minimal structure for slice-based access."""

root: list[JsonValue]


class XComKeysRequest(BaseModel):
"""Request body for fetching multiple XCom values by key list in a single query."""

keys: list[str]
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@
from airflow.api_fastapi.core_api.base import BaseModel
from airflow.api_fastapi.core_api.openapi.exceptions import create_openapi_http_exception_doc
from airflow.api_fastapi.execution_api.datamodels.xcom import (
XComKeysRequest,
XComResponse,
XComSequenceIndexResponse,
XComSequenceSliceResponse,
Expand All @@ -45,7 +46,6 @@ def has_xcom_access(
dag_id: str,
run_id: str,
task_id: str,
xcom_key: Annotated[str, Path(alias="key", min_length=1)],
request: Request,
session: SessionDep,
token=CurrentTIToken,
Expand All @@ -71,6 +71,7 @@ def has_xcom_access(
from airflow.configuration import conf

write = request.method not in {"GET", "HEAD", "OPTIONS"}
xcom_key = request.path_params.get("key")

log.debug(
"Checking %s XCom access for task instance '%s' to XCom '%s' on dag '%s'",
Expand Down Expand Up @@ -116,6 +117,7 @@ def has_xcom_access(
dependencies=[Depends(has_xcom_access)],
)


log = logging.getLogger(__name__)


Expand Down Expand Up @@ -405,6 +407,41 @@ def get_xcom(
return XComResponse(key=key, value=(result[0] if isinstance(result, tuple) else result).value)


@router.post(
"/{dag_id}/{run_id}/{task_id}/keys",
summary="Get multiple XCom values by keys",
description=(
"Fetch multiple XCom values by key list in a single database query. "
"Optimised for XComIterable iteration, reducing N round-trips to one."
),
)
def get_xcom_by_keys(
dag_id: str,
run_id: str,
task_id: str,
request_body: XComKeysRequest,
session: SessionDep,
map_index: Annotated[int, Query()] = -1,
Comment thread
dabla marked this conversation as resolved.
) -> XComSequenceSliceResponse:
"""Fetch multiple XCom values by different keys in a single database query."""
key_list = request_body.keys
if not key_list:
return XComSequenceSliceResponse([])

xcom_read = XComModel.get_many(
run_id=run_id,
task_ids=task_id,
dag_ids=dag_id,
map_indexes=map_index,
)
entity = xcom_entity(xcom_read)
query = (
xcom_read.with_only_columns(entity.key, entity.value).where(entity.key.in_(key_list)).order_by(None)
)
rows = {row.key: row.value for row in session.execute(query)}
return XComSequenceSliceResponse([rows.get(key) for key in key_list])


# TODO: once we have JWT tokens, then remove dag_id/run_id/task_id from the URL and just use the info in
# the token
@router.post(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,7 @@
AddMultiTeamToTIRunContext,
AddStoppedTaskReport,
AddTerminalStateRetryReasonField,
AddXComKeysEndpoint,
IdentifyArchivedTaskStateUpdates,
)

Expand All @@ -73,6 +74,7 @@
AddMultiTeamToTIRunContext,
AddStoppedTaskReport,
IdentifyArchivedTaskStateUpdates,
AddXComKeysEndpoint,
),
Version(
"2026-06-30",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -108,3 +108,13 @@ class AddMultiTeamToTIRunContext(VersionChange):
def remove_multi_team_field(response: ResponseInfo) -> None: # type: ignore[misc]
"""Strip ``multi_team`` from the run context for older clients."""
response.body.pop("multi_team", None)


class AddXComKeysEndpoint(VersionChange):
"""Add the ``xcoms/{dag_id}/{run_id}/{task_id}/keys`` endpoint that fetches several XCom values by key in one query."""

description = __doc__

instructions_to_migrate_to_previous_version = (
endpoint("/xcoms/{dag_id}/{run_id}/{task_id}/keys", ["POST"]).didnt_exist,
)
3 changes: 3 additions & 0 deletions airflow-core/src/airflow/dag_processing/processor.py
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,7 @@
GetVariable,
GetVariableKeys,
GetXCom,
GetXComByKeys,
GetXComCount,
GetXComSequenceItem,
GetXComSequenceSlice,
Expand Down Expand Up @@ -150,6 +151,7 @@ class DagFileParsingResult(BaseModel):
| GetXCom
| GetXComCount
| GetXComSequenceItem
| GetXComByKeys
| GetXComSequenceSlice
| MaskSecret,
Field(discriminator="type"),
Expand Down Expand Up @@ -636,6 +638,7 @@ def _handle_parsing_result(
GetVariable,
GetVariableKeys,
GetXCom,
GetXComByKeys,
GetXComCount,
GetXComSequenceItem,
GetXComSequenceSlice,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -79,11 +79,10 @@ def _(
dag_id: str = Path(),
run_id: str = Path(),
task_id: str = Path(),
xcom_key: str = Path(alias="key"),
token=CurrentTIToken,
):
with create_session() as session:
has_xcom_access(dag_id, run_id, task_id, xcom_key, request, session, token)
has_xcom_access(dag_id, run_id, task_id, request, session, token)
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail={
Expand Down Expand Up @@ -875,3 +874,109 @@ def test_teamless_requester_scoping(self, client, session, dag_maker):

assert forbidden.status_code == 403, forbidden.json()
assert allowed.status_code == 200, allowed.json()


class TestGetXComByKeys:
"""Tests for the POST /{dag_id}/{run_id}/{task_id}/keys batched-fetch endpoint."""

def _insert_xcom(self, session, ti, key, value):
session.add(XComModelV2(key=key, value=value, task_instance_id=ti.id))

def test_returns_values_in_request_key_order(self, client, create_task_instance, session):
"""Values are returned in the same order as the requested keys."""
ti = create_task_instance()
self._insert_xcom(session, ti, "return_value_0", "alpha")
self._insert_xcom(session, ti, "return_value_1", "beta")
self._insert_xcom(session, ti, "return_value_2", "gamma")
session.commit()

response = client.post(
f"/execution/xcoms/{ti.dag_id}/{ti.run_id}/{ti.task_id}/keys",
json={"keys": ["return_value_2", "return_value_0", "return_value_1"]},
)

assert response.status_code == 200
assert response.json() == ["gamma", "alpha", "beta"]

def test_missing_key_returned_as_none(self, client, create_task_instance, session):
"""Keys not found in the database are returned as null (None)."""
ti = create_task_instance()
self._insert_xcom(session, ti, "return_value_0", "exists")
session.commit()

response = client.post(
f"/execution/xcoms/{ti.dag_id}/{ti.run_id}/{ti.task_id}/keys",
json={"keys": ["return_value_0", "return_value_1"]},
)

assert response.status_code == 200
assert response.json() == ["exists", None]

def test_empty_key_list_returns_empty_list(self, client, create_task_instance, session):
ti = create_task_instance()
session.commit()

response = client.post(
f"/execution/xcoms/{ti.dag_id}/{ti.run_id}/{ti.task_id}/keys",
json={"keys": []},
)

assert response.status_code == 200
assert response.json() == []

def test_map_index_filter(self, client, dag_maker, session):
"""Only XCom rows matching the requested map_index are returned."""

class MyOperator(EmptyOperator):
def __init__(self, *, x, **kwargs):
super().__init__(**kwargs)
self.x = x

with dag_maker(dag_id="dag"):
MyOperator.partial(task_id="task").expand(x=["a", "b"])
dag_run = dag_maker.create_dagrun(run_id="run")
tis = {ti.map_index: ti for ti in dag_run.task_instances}

# Insert XComs for map_index=0 and map_index=1 (both have real TI rows).
# map_index=-1 has no TI row for a mapped task, so it is not inserted.
session.add(XComModelV2(key="return_value_0", value="for_index_0", task_instance_id=tis[0].id))
session.add(XComModelV2(key="return_value_0", value="for_index_1", task_instance_id=tis[1].id))
session.commit()

dag_id = tis[0].dag_id
task_id = tis[0].task_id

# Default map_index=-1: no rows stored there, so null is returned.
response_default = client.post(
f"/execution/xcoms/{dag_id}/{dag_run.run_id}/{task_id}/keys",
json={"keys": ["return_value_0"]},
)
response_index_0 = client.post(
f"/execution/xcoms/{dag_id}/{dag_run.run_id}/{task_id}/keys",
params={"map_index": 0},
json={"keys": ["return_value_0"]},
)
response_index_1 = client.post(
f"/execution/xcoms/{dag_id}/{dag_run.run_id}/{task_id}/keys",
params={"map_index": 1},
json={"keys": ["return_value_0"]},
)

assert response_default.json() == [None]
assert response_index_0.json() == ["for_index_0"]
assert response_index_1.json() == ["for_index_1"]

def test_does_not_return_other_tasks_xcoms(self, client, create_task_instance, session):
"""Keys from a different task_id are not returned."""
ti = create_task_instance()
other_ti = create_task_instance(dag_id="other_dag", task_id="other_task")
self._insert_xcom(session, other_ti, "return_value_0", "not_mine")
session.commit()

response = client.post(
f"/execution/xcoms/{ti.dag_id}/{ti.run_id}/{ti.task_id}/keys",
json={"keys": ["return_value_0"]},
)

assert response.status_code == 200
assert response.json() == [None]
1 change: 1 addition & 0 deletions airflow-core/tests/unit/jobs/test_triggerer_job.py
Original file line number Diff line number Diff line change
Expand Up @@ -3054,6 +3054,7 @@ def get_type_names(union_type):
"GetPreviousDagRun",
"GetTaskBreadcrumbs",
"GetTaskRescheduleStartDate",
"GetXComByKeys",
"GetXComCount",
"GetXComSequenceItem",
"GetXComSequenceSlice",
Expand Down
Loading
Loading