Skip to content

Commit 2193863

Browse files
committed
Read a mapped upstream's XCom sequence in chunks instead of one item per request
LazyXComSequence fetched one item per GetXComSequenceItem request: iterating a mapped upstream's results, or an iterated task reading them by index, cost one supervisor round trip per item, which is the dominant cost for large inputs. Reads now come in chunks. An index outside the slice held fetches chunk_size items starting there with one GetXComSequenceSlice request, the message the slice path already used, and consecutive reads are served from it. Sequential access costs one request per chunk instead of one per item, both through __getitem__/__iter__ (send) and aget/__aiter__ (asend), and at most one chunk is held. A jump backwards fetches again from there. The chunk size is the new [core] xcom_sequence_chunk_size option, default 32, documented in config.yml and in the iteration docs; a LazyXComSequence can also be given one explicitly. A negative index no longer needs its own per-item request: it is mapped from the end through the cached length, one GetXComCount at most. The client no longer sends GetXComSequenceItem at all; the request handler keeps serving it.
1 parent 42259a9 commit 2193863

5 files changed

Lines changed: 204 additions & 153 deletions

File tree

‎airflow-core/src/airflow/config_templates/config.yml‎

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -493,6 +493,15 @@ core:
493493
type: integer
494494
example: ~
495495
default: "1024"
496+
xcom_sequence_chunk_size:
497+
description: |
498+
Number of items fetched per request when a mapped task's XCom results are read as a sequence,
499+
for example by a downstream ``.iterate()`` or a ``for`` loop over them. Larger values mean fewer
500+
round trips to the API server; each fetched chunk is held in memory until the reader moves past it.
501+
version_added: 3.4.0
502+
type: integer
503+
example: ~
504+
default: "32"
496505

497506
allow_double_dot_in_ids:
498507
description: |

‎task-sdk/docs/mapped-tasks-vs-iterable-tasks.rst‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -319,6 +319,12 @@ concurrent approach is dramatically faster — see the
319319
``asyncio.to_thread()`` or ``contextvars.copy_context().run()``, which carry the iteration's
320320
context over.
321321

322+
.. note::
323+
324+
When the input of ``iterate()`` is the output of a mapped task, its items are fetched from the
325+
API server in chunks of ``[core] xcom_sequence_chunk_size`` items (32 by default), one request
326+
per chunk, with one chunk held in memory at a time.
327+
322328
Why Iterable Tasks?
323329
---------------------------
324330

‎task-sdk/src/airflow/sdk/execution_time/lazy_sequence.py‎

Lines changed: 70 additions & 62 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,8 @@
2525
import attrs
2626
import structlog
2727

28+
from airflow.sdk.configuration import conf
29+
2830
if TYPE_CHECKING:
2931
from airflow.sdk.definitions.xcom_arg import PlainXComArg
3032
from airflow.sdk.execution_time.task_runner import RuntimeTaskInstance
@@ -38,6 +40,15 @@
3840

3941
log = structlog.get_logger(logger_name=__name__)
4042

43+
#: Default for ``[core] xcom_sequence_chunk_size``: items a ``LazyXComSequence`` fetches per request
44+
#: when read by index or iterated. One slice request serves this many consecutive reads, and at
45+
#: most this many values are held at a time.
46+
XCOM_SEQUENCE_CHUNK_SIZE = 32
47+
48+
49+
def _configured_chunk_size() -> int:
50+
return conf.getint("core", "xcom_sequence_chunk_size", fallback=XCOM_SEQUENCE_CHUNK_SIZE)
51+
4152

4253
@attrs.define
4354
class LazyXComIterator(Iterator[T]):
@@ -87,10 +98,23 @@ async def __anext__(self) -> T:
8798

8899
@attrs.define
89100
class LazyXComSequence(Sequence[T]):
101+
"""
102+
A mapped upstream's return values, read from the API server on demand.
103+
104+
Reads come in chunks: an index outside the slice held fetches ``chunk_size`` items starting at
105+
that index with one ``GetXComSequenceSlice`` request, so iterating, or an iterated task reading
106+
this by index, costs one request per chunk instead of one per item, while no more than one
107+
chunk is held in memory. ``chunk_size`` defaults to ``[core] xcom_sequence_chunk_size``. A
108+
negative index counts from the end through the cached length.
109+
"""
110+
90111
_len: int | None = attrs.field(init=False, default=None)
91112
_xcom_arg: PlainXComArg = attrs.field(alias="xcom_arg")
92113
_ti: RuntimeTaskInstance = attrs.field(alias="ti")
93114
_previous_iteration: bool = attrs.field(default=False, alias="previous_iteration", kw_only=True)
115+
chunk_size: int = attrs.field(kw_only=True, factory=_configured_chunk_size)
116+
_chunk_start: int = attrs.field(init=False, default=0)
117+
_chunk: list[T] = attrs.field(init=False, factory=list)
94118

95119
def __repr__(self) -> str:
96120
if self._len is not None:
@@ -117,31 +141,50 @@ def __aiter__(self) -> AsyncIterator[T]:
117141
return AsyncLazyXComIterator(seq=self)
118142

119143
async def aget(self, index: int) -> T:
120-
"""Async counterpart of ``self[index]``; the same ``GetXComSequenceItem`` request via ``asend``."""
121-
from airflow.sdk.execution_time.comms import (
122-
ErrorResponse,
123-
GetXComSequenceItem,
124-
XComSequenceIndexResult,
125-
)
144+
"""Async counterpart of ``self[index]``: the same chunked reads, sent with ``asend``."""
126145
from airflow.sdk.execution_time.task_runner import SUPERVISOR_COMMS
127-
from airflow.sdk.execution_time.xcom import XCom
146+
147+
if index < 0:
148+
# range() maps a negative index from the end and raises IndexError past the front;
149+
# only a negative one needs the length, so a forward read sends no count request.
150+
index = range(await self.alen())[index]
151+
if not self._holds(index):
152+
msg = await SUPERVISOR_COMMS.asend(self._slice_request(index, index + self.chunk_size))
153+
self._hold(index, self._slice_values(msg))
154+
return self._chunk[index - self._chunk_start]
155+
156+
def _holds(self, index: int) -> bool:
157+
return self._chunk_start <= index < self._chunk_start + len(self._chunk)
158+
159+
def _hold(self, index: int, values: list[T]) -> None:
160+
"""Keep the slice fetched for ``index``; an empty slice means ``index`` is past the end."""
161+
if not values:
162+
raise IndexError(index)
163+
self._chunk_start, self._chunk = index, values
164+
165+
def _slice_request(self, start: int | None, stop: int | None, step: int | None = None) -> Any:
166+
from airflow.sdk.execution_time.comms import GetXComSequenceSlice
128167

129168
source = (xcom_arg := self._xcom_arg).operator
130-
msg = await SUPERVISOR_COMMS.asend(
131-
GetXComSequenceItem(
132-
previous_iteration=self._previous_iteration,
133-
key=xcom_arg.key,
134-
dag_id=source.dag_id,
135-
task_id=source.task_id,
136-
run_id=self._ti.run_id,
137-
offset=index,
138-
),
169+
return GetXComSequenceSlice(
170+
previous_iteration=self._previous_iteration,
171+
key=xcom_arg.key,
172+
dag_id=source.dag_id,
173+
task_id=source.task_id,
174+
run_id=self._ti.run_id,
175+
start=start,
176+
stop=stop,
177+
step=step,
139178
)
140-
if isinstance(msg, ErrorResponse):
141-
raise IndexError(index)
142-
if not isinstance(msg, XComSequenceIndexResult):
143-
raise TypeError(f"Got unexpected response to GetXComSequenceItem: {msg!r}")
144-
return XCom.deserialize_value(_XComWrapper(msg.root))
179+
180+
@staticmethod
181+
def _slice_values(msg: Any) -> list[T]:
182+
from airflow.sdk.execution_time.comms import XComSequenceSliceResult
183+
from airflow.sdk.execution_time.xcom import XCom
184+
185+
if not isinstance(msg, XComSequenceSliceResult):
186+
raise TypeError(f"Got unexpected response to GetXComSequenceSlice: {msg!r}")
187+
return [XCom._load(_XComWrapper(value)) for value in msg.root]
145188

146189
def __len__(self) -> int:
147190
if self._len is None:
@@ -197,56 +240,21 @@ def __getitem__(self, key: int) -> T: ...
197240
def __getitem__(self, key: slice) -> Sequence[T]: ...
198241

199242
def __getitem__(self, key: int | slice) -> T | Sequence[T]:
200-
from airflow.sdk.execution_time.comms import (
201-
ErrorResponse,
202-
GetXComSequenceItem,
203-
GetXComSequenceSlice,
204-
XComSequenceIndexResult,
205-
XComSequenceSliceResult,
206-
)
207243
from airflow.sdk.execution_time.task_runner import SUPERVISOR_COMMS
208-
from airflow.sdk.execution_time.xcom import XCom
209244

210245
if isinstance(key, slice):
211-
start, stop, step = _coerce_slice(key)
212-
source = (xcom_arg := self._xcom_arg).operator
213-
msg = SUPERVISOR_COMMS.send(
214-
GetXComSequenceSlice(
215-
previous_iteration=self._previous_iteration,
216-
key=xcom_arg.key,
217-
dag_id=source.dag_id,
218-
task_id=source.task_id,
219-
run_id=self._ti.run_id,
220-
start=start,
221-
stop=stop,
222-
step=step,
223-
),
224-
)
225-
if not isinstance(msg, XComSequenceSliceResult):
226-
raise TypeError(f"Got unexpected response to GetXComSequenceSlice: {msg!r}")
227-
return [XCom._load(_XComWrapper(value)) for value in msg.root]
246+
return self._slice_values(SUPERVISOR_COMMS.send(self._slice_request(*_coerce_slice(key))))
228247

229248
if not isinstance(key, int):
230249
if (index := getattr(key, "__index__", None)) is not None:
231250
key = index()
232251
raise TypeError(f"Sequence indices must be integers or slices not {type(key).__name__}")
233252

234-
source = (xcom_arg := self._xcom_arg).operator
235-
msg = SUPERVISOR_COMMS.send(
236-
GetXComSequenceItem(
237-
previous_iteration=self._previous_iteration,
238-
key=xcom_arg.key,
239-
dag_id=source.dag_id,
240-
task_id=source.task_id,
241-
run_id=self._ti.run_id,
242-
offset=key,
243-
),
244-
)
245-
if isinstance(msg, ErrorResponse):
246-
raise IndexError(key)
247-
if not isinstance(msg, XComSequenceIndexResult):
248-
raise TypeError(f"Got unexpected response to GetXComSequenceItem: {msg!r}")
249-
return XCom._load(_XComWrapper(msg.root))
253+
index = range(len(self))[key] if key < 0 else key
254+
if not self._holds(index):
255+
msg = SUPERVISOR_COMMS.send(self._slice_request(index, index + self.chunk_size))
256+
self._hold(index, self._slice_values(msg))
257+
return self._chunk[index - self._chunk_start]
250258

251259

252260
def _coerce_slice_index(value: Any) -> int | None:

‎task-sdk/tests/task_sdk/definitions/test_mappedoperator.py‎

Lines changed: 2 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -32,15 +32,12 @@
3232
from airflow.sdk.definitions.mappedoperator import MappedOperator
3333
from airflow.sdk.definitions.xcom_arg import XComArg
3434
from airflow.sdk.execution_time.comms import (
35-
ErrorResponse,
3635
GetTICount,
3736
GetXCom,
38-
GetXComSequenceItem,
3937
GetXComSequenceSlice,
4038
SetXCom,
4139
TICount,
4240
XComResult,
43-
XComSequenceIndexResult,
4441
XComSequenceSliceResult,
4542
)
4643

@@ -695,17 +692,10 @@ def mock_comms_response(msg):
695692
value = [v for k, v in expected_values.items() if k[0] == msg.task_id]
696693
return XComResult(key=BaseXCom.XCOM_RETURN_KEY, value=value)
697694
elif isinstance(msg, GetXComSequenceSlice):
698-
# Handle sequence slicing for pulling all XCom values from mapped tasks
695+
# The aggregated task-group value resolves lazily and is read in chunks: honour the bounds.
699696
task_id = msg.task_id
700697
values = [v for k, v in expected_values.items() if k[0] == task_id and k[1] is not None]
701-
return XComSequenceSliceResult(root=values)
702-
elif isinstance(msg, GetXComSequenceItem):
703-
# The aggregated task-group value now resolves lazily, so iterating it fetches one item at a time.
704-
task_id = msg.task_id
705-
values = [v for k, v in expected_values.items() if k[0] == task_id and k[1] is not None]
706-
if 0 <= msg.offset < len(values):
707-
return XComSequenceIndexResult(root=values[msg.offset])
708-
return ErrorResponse()
698+
return XComSequenceSliceResult(root=values[msg.start : msg.stop : msg.step])
709699
elif isinstance(msg, GetTICount):
710700
# Handle TI count queries for upstream_map_indexes computation
711701
if msg.task_ids:

0 commit comments

Comments
 (0)