This is an automated email from the ASF dual-hosted git repository.
amoghdesai pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/airflow.git
The following commit(s) were added to refs/heads/main by this push:
new 25f7fe33620 Improve `xcom_pull` to cover different scenarios for
mapped tasks (#51568)
25f7fe33620 is described below
commit 25f7fe3362074085a5ae0f5aab6fa831e40e966b
Author: Amogh Desai <[email protected]>
AuthorDate: Thu Jun 12 09:04:34 2025 +0530
Improve `xcom_pull` to cover different scenarios for mapped tasks (#51568)
---
task-sdk/src/airflow/sdk/bases/xcom.py | 59 +++++++++++++-
.../src/airflow/sdk/execution_time/task_runner.py | 30 ++++---
.../task_sdk/execution_time/test_task_runner.py | 94 ++++++++++++++++------
3 files changed, 148 insertions(+), 35 deletions(-)
diff --git a/task-sdk/src/airflow/sdk/bases/xcom.py
b/task-sdk/src/airflow/sdk/bases/xcom.py
index 0c330652956..c9b777daca3 100644
--- a/task-sdk/src/airflow/sdk/bases/xcom.py
+++ b/task-sdk/src/airflow/sdk/bases/xcom.py
@@ -21,7 +21,14 @@ from typing import Any, Protocol
import structlog
-from airflow.sdk.execution_time.comms import DeleteXCom, GetXCom, SetXCom,
XComResult
+from airflow.sdk.execution_time.comms import (
+ DeleteXCom,
+ GetXCom,
+ GetXComSequenceSlice,
+ SetXCom,
+ XComResult,
+ XComSequenceSliceResult,
+)
log = structlog.get_logger(logger_name="task")
@@ -274,6 +281,56 @@ class BaseXCom:
)
return None
+ @classmethod
+ def get_all(
+ cls,
+ *,
+ key: str,
+ dag_id: str,
+ task_id: str,
+ run_id: str,
+ ) -> Any:
+ """
+ Retrieve all XCom values for a task, typically from all map indexes.
+
+ XComSequenceSliceResult can never have *None* in it, it returns an
empty list
+ if no values were found.
+
+ This is particularly useful for getting all XCom values from all map
+ indexes of a mapped task at once.
+
+ :param key: A key for the XCom. Only XComs with this key will be
returned.
+ :param run_id: DAG run ID for the task.
+ :param dag_id: DAG ID to pull XComs from.
+ :param task_id: Task ID to pull XComs from.
+ :return: List of all XCom values if found.
+ """
+ from airflow.sdk.execution_time.task_runner import SUPERVISOR_COMMS
+
+ # Since Triggers can hit this code path via `sync_to_async` (which
uses threads internally)
+ # we need to make sure that we "atomically" send a request and get the
response to that
+ # back so that two triggers don't end up interleaving requests and
create a possible
+ # race condition where the wrong trigger reads the response.
+ with SUPERVISOR_COMMS.lock:
+ SUPERVISOR_COMMS.send_request(
+ log=log,
+ msg=GetXComSequenceSlice(
+ key=key,
+ dag_id=dag_id,
+ task_id=task_id,
+ run_id=run_id,
+ start=None,
+ stop=None,
+ step=None,
+ ),
+ )
+ msg = SUPERVISOR_COMMS.get_message()
+
+ if not isinstance(msg, XComSequenceSliceResult):
+ raise TypeError(f"Expected XComSequenceSliceResult, received:
{type(msg)} {msg}")
+
+ return msg.root
+
@staticmethod
def serialize_value(
value: Any,
diff --git a/task-sdk/src/airflow/sdk/execution_time/task_runner.py
b/task-sdk/src/airflow/sdk/execution_time/task_runner.py
index bbc7394a87a..2374520e63f 100644
--- a/task-sdk/src/airflow/sdk/execution_time/task_runner.py
+++ b/task-sdk/src/airflow/sdk/execution_time/task_runner.py
@@ -338,7 +338,7 @@ class RuntimeTaskInstance(TaskInstance):
run_id = self.run_id
single_task_requested = isinstance(task_ids, (str, type(None)))
- single_map_index_requested = isinstance(map_indexes, (int, type(None),
ArgNotSet))
+ single_map_index_requested = isinstance(map_indexes, (int, type(None)))
if task_ids is None:
# default to the current task if not provided
@@ -346,11 +346,27 @@ class RuntimeTaskInstance(TaskInstance):
elif isinstance(task_ids, str):
task_ids = [task_ids]
- map_indexes_iterable: Iterable[int | None] = []
- # If map_indexes is not provided, default to use the map_index of the
calling task
+ # If map_indexes is not specified, pull xcoms from all map indexes for
each task
if isinstance(map_indexes, ArgNotSet):
- map_indexes_iterable = [self.map_index]
- elif isinstance(map_indexes, int) or map_indexes is None:
+ xcoms = [
+ value
+ for t_id in task_ids
+ for value in XCom.get_all(
+ run_id=run_id,
+ key=key,
+ task_id=t_id,
+ dag_id=dag_id,
+ )
+ ]
+
+ # For single task pulling from unmapped task, return single value
+ if single_task_requested and len(xcoms) == 1:
+ return xcoms[0]
+ return xcoms
+
+ # Original logic when map_indexes is explicitly specified
+ map_indexes_iterable: Iterable[int | None] = []
+ if isinstance(map_indexes, int) or map_indexes is None:
map_indexes_iterable = [map_indexes]
elif isinstance(map_indexes, Iterable):
map_indexes_iterable = map_indexes
@@ -360,10 +376,6 @@ class RuntimeTaskInstance(TaskInstance):
)
xcoms = []
- # TODO: AIP 72 Execution API only allows working with a single
map_index at a time
- # this is inefficient and leads to task_id * map_index requests to the
API.
- # And we can't achieve the original behavior of XCom pull with
multiple tasks
- # directly now.
for t_id, m_idx in product(task_ids, map_indexes_iterable):
value = XCom.get_one(
run_id=run_id,
diff --git a/task-sdk/tests/task_sdk/execution_time/test_task_runner.py
b/task-sdk/tests/task_sdk/execution_time/test_task_runner.py
index a994e995bac..d786fdaa5b2 100644
--- a/task-sdk/tests/task_sdk/execution_time/test_task_runner.py
+++ b/task-sdk/tests/task_sdk/execution_time/test_task_runner.py
@@ -77,6 +77,7 @@ from airflow.sdk.execution_time.comms import (
GetTICount,
GetVariable,
GetXCom,
+ GetXComSequenceSlice,
OKResponse,
PrevSuccessfulDagRunResult,
SetRenderedFields,
@@ -91,6 +92,7 @@ from airflow.sdk.execution_time.comms import (
TriggerDagRun,
VariableResult,
XComResult,
+ XComSequenceSliceResult,
)
from airflow.sdk.execution_time.context import (
ConnectionAccessor,
@@ -1113,7 +1115,7 @@ class TestRuntimeTaskInstance:
task = BaseOperator(task_id="hello")
# Assume the context is sent from the API server
- # `task_sdk/tests/api/test_client.py::test_task_instance_start` checks
the context is received
+ # `task-sdk/tests/api/test_client.py::test_task_instance_start` checks
the context is received
# from the API server
runtime_ti = create_runtime_ti(task=task, dag_id="basic_task")
@@ -1387,7 +1389,17 @@ class TestRuntimeTaskInstance:
runtime_ti = create_runtime_ti(task=task, **extra_for_ti)
ser_value = BaseXCom.serialize_value(xcom_values)
- mock_supervisor_comms.get_message.return_value = XComResult(key="key",
value=ser_value)
+
+ def mock_get_message_side_effect(*args, **kwargs):
+ calls = mock_supervisor_comms.send_request.call_args_list
+ if calls:
+ last_call = calls[-1]
+ msg = last_call[1]["msg"]
+ if isinstance(msg, GetXComSequenceSlice):
+ return XComSequenceSliceResult(root=[ser_value])
+ return XComResult(key="key", value=ser_value)
+
+ mock_supervisor_comms.get_message.side_effect =
mock_get_message_side_effect
run(runtime_ti, context=runtime_ti.get_template_context(),
log=mock.MagicMock())
@@ -1403,17 +1415,30 @@ class TestRuntimeTaskInstance:
task_id = test_task_id
for map_index in map_indexes:
if map_index == NOTSET:
- map_index = -1
- mock_supervisor_comms.send_request.assert_any_call(
- log=mock.ANY,
- msg=GetXCom(
- key="key",
- dag_id="test_dag",
- run_id="test_run",
- task_id=task_id,
- map_index=map_index,
- ),
- )
+ mock_supervisor_comms.send_request.assert_any_call(
+ log=mock.ANY,
+ msg=GetXComSequenceSlice(
+ key="key",
+ dag_id="test_dag",
+ run_id="test_run",
+ task_id=task_id,
+ start=None,
+ stop=None,
+ step=None,
+ ),
+ )
+ else:
+ expected_map_index = map_index if map_index is not None
else None
+ mock_supervisor_comms.send_request.assert_any_call(
+ log=mock.ANY,
+ msg=GetXCom(
+ key="key",
+ dag_id="test_dag",
+ run_id="test_run",
+ task_id=task_id,
+ map_index=expected_map_index,
+ ),
+ )
@pytest.mark.parametrize(
"task_ids, map_indexes, expected_value",
@@ -1421,7 +1446,6 @@ class TestRuntimeTaskInstance:
pytest.param("task_a", 0, {"a": 1, "b": 2}, id="task_id is str,
map_index is int"),
pytest.param("task_a", [0], [{"a": 1, "b": 2}], id="task_id is
str, map_index is list"),
pytest.param("task_a", None, {"a": 1, "b": 2}, id="task_id is str,
map_index is None"),
- pytest.param("task_a", NOTSET, {"a": 1, "b": 2}, id="task_id is
str, map_index is ArgNotSet"),
pytest.param(["task_a"], 0, [{"a": 1, "b": 2}], id="task_id is
list, map_index is int"),
pytest.param(["task_a"], [0], [{"a": 1, "b": 2}], id="task_id is
list, map_index is list"),
pytest.param(["task_a"], None, [{"a": 1, "b": 2}], id="task_id is
list, map_index is None"),
@@ -1431,6 +1455,13 @@ class TestRuntimeTaskInstance:
pytest.param(None, 0, {"a": 1, "b": 2}, id="task_id is None,
map_index is int"),
pytest.param(None, [0], [{"a": 1, "b": 2}], id="task_id is None,
map_index is list"),
pytest.param(None, None, {"a": 1, "b": 2}, id="task_id is None,
map_index is None"),
+ pytest.param(
+ ["task_a", "task_b"],
+ NOTSET,
+ [{"a": 1, "b": 2}, {"c": 3, "d": 4}],
+ id="multiple task_ids, map_index is ArgNotSet",
+ ),
+ pytest.param("task_a", NOTSET, {"a": 1, "b": 2}, id="task_id is
str, map_index is ArgNotSet"),
pytest.param(None, NOTSET, {"a": 1, "b": 2}, id="task_id is None,
map_index is ArgNotSet"),
],
)
@@ -1444,7 +1475,7 @@ class TestRuntimeTaskInstance:
):
"""
Tests return value of xcom_pull under various combinations of task_ids
and map_indexes.
- The above test covers the expected calls to supervisor comms.
+ Also verifies the correct XCom method (get_one vs get_all) is called.
"""
class CustomOperator(BaseOperator):
@@ -1455,13 +1486,28 @@ class TestRuntimeTaskInstance:
task = CustomOperator(task_id=test_task_id)
runtime_ti = create_runtime_ti(task=task)
- value = {"a": 1, "b": 2}
- # API server returns serialised value for xcom result, staging it in
that way
- xcom_value = BaseXCom.serialize_value(value)
- mock_supervisor_comms.get_message.return_value = XComResult(key="key",
value=xcom_value)
-
- returned_xcom = runtime_ti.xcom_pull(key="key", task_ids=task_ids,
map_indexes=map_indexes)
- assert returned_xcom == expected_value
+ with patch.object(XCom, "get_one") as mock_get_one, patch.object(XCom,
"get_all") as mock_get_all:
+ if map_indexes == NOTSET:
+ # Use side_effect to return different values for different
tasks
+ def mock_get_all_side_effect(task_id, **kwargs):
+ if task_id == "task_b":
+ return [{"c": 3, "d": 4}]
+ return [{"a": 1, "b": 2}]
+
+ mock_get_all.side_effect = mock_get_all_side_effect
+ mock_get_one.return_value = None
+ else:
+ mock_get_one.return_value = {"a": 1, "b": 2}
+ mock_get_all.return_value = None
+
+ xcom = runtime_ti.xcom_pull(key="key", task_ids=task_ids,
map_indexes=map_indexes)
+ assert xcom == expected_value
+ if map_indexes == NOTSET:
+ assert mock_get_all.called
+ assert not mock_get_one.called
+ else:
+ assert mock_get_one.called
+ assert not mock_get_all.called
def test_get_param_from_context(
self, mocked_parse, make_ti_context, mock_supervisor_comms,
create_runtime_ti
@@ -1910,13 +1956,11 @@ class TestXComAfterTaskExecution:
runtime_ti = create_runtime_ti(task=task)
run(runtime_ti, context=runtime_ti.get_template_context(),
log=mock.MagicMock())
- mock_xcom_backend.get_one.assert_called_once_with(
+ mock_xcom_backend.get_all.assert_called_once_with(
key="key",
dag_id="test_dag",
task_id="pull_task",
run_id="test_run",
- map_index=-1,
- include_prior_dates=False,
)
assert not any(