Skip to content

Commit 739ffb6

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 1e566d4 commit 739ffb6

5 files changed

Lines changed: 207 additions & 148 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
@@ -256,6 +256,12 @@ concurrent approach is dramatically faster — see the
256256
combines more than once. With ``expand()`` each task instance runs in its own process and
257257
gets its own copy. Treat inputs as read-only, or copy what the task changes in place.
258258

259+
.. note::
260+
261+
When the input of ``iterate()`` is the output of a mapped task, its items are fetched from the
262+
API server in chunks of ``[core] xcom_sequence_chunk_size`` items (32 by default), one request
263+
per chunk, with one chunk held in memory at a time.
264+
259265
Why Iterable Tasks?
260266
---------------------------
261267

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

Lines changed: 76 additions & 59 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,9 +98,22 @@ 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")
114+
chunk_size: int = attrs.field(kw_only=True, factory=_configured_chunk_size)
115+
_chunk_start: int = attrs.field(init=False, default=0)
116+
_chunk: list[T] = attrs.field(init=False, factory=list)
93117

94118
def __repr__(self) -> str:
95119
if self._len is not None:
@@ -116,30 +140,47 @@ def __aiter__(self) -> AsyncIterator[T]:
116140
return AsyncLazyXComIterator(seq=self)
117141

118142
async def aget(self, index: int) -> T:
119-
"""Async counterpart of ``self[index]``; the same ``GetXComSequenceItem`` request via ``asend``."""
120-
from airflow.sdk.execution_time.comms import (
121-
ErrorResponse,
122-
GetXComSequenceItem,
123-
XComSequenceIndexResult,
124-
)
143+
"""Async counterpart of ``self[index]``: the same chunked reads, sent with ``asend``."""
125144
from airflow.sdk.execution_time.task_runner import SUPERVISOR_COMMS
126-
from airflow.sdk.execution_time.xcom import XCom
145+
146+
if index < 0:
147+
index = _normalize_index(index, await self.alen())
148+
if not self._holds(index):
149+
msg = await SUPERVISOR_COMMS.asend(self._slice_request(index, index + self.chunk_size))
150+
self._hold(index, self._slice_values(msg))
151+
return self._chunk[index - self._chunk_start]
152+
153+
def _holds(self, index: int) -> bool:
154+
return self._chunk_start <= index < self._chunk_start + len(self._chunk)
155+
156+
def _hold(self, index: int, values: list[T]) -> None:
157+
"""Keep the slice fetched for ``index``; an empty slice means ``index`` is past the end."""
158+
if not values:
159+
raise IndexError(index)
160+
self._chunk_start, self._chunk = index, values
161+
162+
def _slice_request(self, start: int | None, stop: int | None, step: int | None = None) -> Any:
163+
from airflow.sdk.execution_time.comms import GetXComSequenceSlice
127164

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

144185
def __len__(self) -> int:
145186
if self._len is None:
@@ -193,54 +234,30 @@ def __getitem__(self, key: int) -> T: ...
193234
def __getitem__(self, key: slice) -> Sequence[T]: ...
194235

195236
def __getitem__(self, key: int | slice) -> T | Sequence[T]:
196-
from airflow.sdk.execution_time.comms import (
197-
ErrorResponse,
198-
GetXComSequenceItem,
199-
GetXComSequenceSlice,
200-
XComSequenceIndexResult,
201-
XComSequenceSliceResult,
202-
)
203237
from airflow.sdk.execution_time.task_runner import SUPERVISOR_COMMS
204-
from airflow.sdk.execution_time.xcom import XCom
205238

206239
if isinstance(key, slice):
207-
start, stop, step = _coerce_slice(key)
208-
source = (xcom_arg := self._xcom_arg).operator
209-
msg = SUPERVISOR_COMMS.send(
210-
GetXComSequenceSlice(
211-
key=xcom_arg.key,
212-
dag_id=source.dag_id,
213-
task_id=source.task_id,
214-
run_id=self._ti.run_id,
215-
start=start,
216-
stop=stop,
217-
step=step,
218-
),
219-
)
220-
if not isinstance(msg, XComSequenceSliceResult):
221-
raise TypeError(f"Got unexpected response to GetXComSequenceSlice: {msg!r}")
222-
return [XCom.deserialize_value(_XComWrapper(value)) for value in msg.root]
240+
return self._slice_values(SUPERVISOR_COMMS.send(self._slice_request(*_coerce_slice(key))))
223241

224242
if not isinstance(key, int):
225243
if (index := getattr(key, "__index__", None)) is not None:
226244
key = index()
227245
raise TypeError(f"Sequence indices must be integers or slices not {type(key).__name__}")
228246

229-
source = (xcom_arg := self._xcom_arg).operator
230-
msg = SUPERVISOR_COMMS.send(
231-
GetXComSequenceItem(
232-
key=xcom_arg.key,
233-
dag_id=source.dag_id,
234-
task_id=source.task_id,
235-
run_id=self._ti.run_id,
236-
offset=key,
237-
),
238-
)
239-
if isinstance(msg, ErrorResponse):
240-
raise IndexError(key)
241-
if not isinstance(msg, XComSequenceIndexResult):
242-
raise TypeError(f"Got unexpected response to GetXComSequenceItem: {msg!r}")
243-
return XCom.deserialize_value(_XComWrapper(msg.root))
247+
index = _normalize_index(key, len(self)) if key < 0 else key
248+
if not self._holds(index):
249+
msg = SUPERVISOR_COMMS.send(self._slice_request(index, index + self.chunk_size))
250+
self._hold(index, self._slice_values(msg))
251+
return self._chunk[index - self._chunk_start]
252+
253+
254+
def _normalize_index(index: int, length: int) -> int:
255+
"""Map a negative index onto its position from the start, or raise ``IndexError`` past the front."""
256+
if index < 0:
257+
index += length
258+
if index < 0:
259+
raise IndexError(index)
260+
return index
244261

245262

246263
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

@@ -679,17 +676,10 @@ def mock_comms_response(msg):
679676
value = [v for k, v in expected_values.items() if k[0] == msg.task_id]
680677
return XComResult(key=BaseXCom.XCOM_RETURN_KEY, value=value)
681678
elif isinstance(msg, GetXComSequenceSlice):
682-
# Handle sequence slicing for pulling all XCom values from mapped tasks
679+
# The aggregated task-group value resolves lazily and is read in chunks: honour the bounds.
683680
task_id = msg.task_id
684681
values = [v for k, v in expected_values.items() if k[0] == task_id and k[1] is not None]
685-
return XComSequenceSliceResult(root=values)
686-
elif isinstance(msg, GetXComSequenceItem):
687-
# The aggregated task-group value now resolves lazily, so iterating it fetches one item at a time.
688-
task_id = msg.task_id
689-
values = [v for k, v in expected_values.items() if k[0] == task_id and k[1] is not None]
690-
if 0 <= msg.offset < len(values):
691-
return XComSequenceIndexResult(root=values[msg.offset])
692-
return ErrorResponse()
682+
return XComSequenceSliceResult(root=values[msg.start : msg.stop : msg.step])
693683
elif isinstance(msg, GetTICount):
694684
# Handle TI count queries for upstream_map_indexes computation
695685
if msg.task_ids:

0 commit comments

Comments
 (0)