Skip to content

Commit 89b6cc1

Browse files
dablaclaude
andcommitted
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 the XCOM_SEQUENCE_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. 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. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
1 parent dbf2bdc commit 89b6cc1

3 files changed

Lines changed: 162 additions & 148 deletions

File tree

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

Lines changed: 68 additions & 59 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,10 @@
3838

3939
log = structlog.get_logger(logger_name=__name__)
4040

41+
#: Items a ``LazyXComSequence`` fetches per request when read by index or iterated. One slice
42+
#: request serves this many consecutive reads, and at most this many values are held at a time.
43+
XCOM_SEQUENCE_CHUNK_SIZE = 32
44+
4145

4246
@attrs.define
4347
class LazyXComIterator(Iterator[T]):
@@ -87,9 +91,21 @@ async def __anext__(self) -> T:
8791

8892
@attrs.define
8993
class LazyXComSequence(Sequence[T]):
94+
"""
95+
A mapped upstream's return values, read from the API server on demand.
96+
97+
Reads come in chunks: an index outside the slice held fetches the ``XCOM_SEQUENCE_CHUNK_SIZE``
98+
items starting at that index with one ``GetXComSequenceSlice`` request, so iterating, or an
99+
iterated task reading this by index, costs one request per chunk instead of one per item,
100+
while no more than one chunk is held in memory. A negative index counts from the end through
101+
the cached length.
102+
"""
103+
90104
_len: int | None = attrs.field(init=False, default=None)
91105
_xcom_arg: PlainXComArg = attrs.field(alias="xcom_arg")
92106
_ti: RuntimeTaskInstance = attrs.field(alias="ti")
107+
_chunk_start: int = attrs.field(init=False, default=0)
108+
_chunk: list[T] = attrs.field(init=False, factory=list)
93109

94110
def __repr__(self) -> str:
95111
if self._len is not None:
@@ -116,30 +132,47 @@ def __aiter__(self) -> AsyncIterator[T]:
116132
return AsyncLazyXComIterator(seq=self)
117133

118134
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-
)
135+
"""Async counterpart of ``self[index]``: the same chunked reads, sent with ``asend``."""
125136
from airflow.sdk.execution_time.task_runner import SUPERVISOR_COMMS
126-
from airflow.sdk.execution_time.xcom import XCom
137+
138+
if index < 0:
139+
index = _normalize_index(index, await self.alen())
140+
if not self._holds(index):
141+
msg = await SUPERVISOR_COMMS.asend(self._slice_request(index, index + XCOM_SEQUENCE_CHUNK_SIZE))
142+
self._hold(index, self._slice_values(msg))
143+
return self._chunk[index - self._chunk_start]
144+
145+
def _holds(self, index: int) -> bool:
146+
return self._chunk_start <= index < self._chunk_start + len(self._chunk)
147+
148+
def _hold(self, index: int, values: list[T]) -> None:
149+
"""Keep the slice fetched for ``index``; an empty slice means ``index`` is past the end."""
150+
if not values:
151+
raise IndexError(index)
152+
self._chunk_start, self._chunk = index, values
153+
154+
def _slice_request(self, start: int | None, stop: int | None, step: int | None = None) -> Any:
155+
from airflow.sdk.execution_time.comms import GetXComSequenceSlice
127156

128157
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-
),
158+
return GetXComSequenceSlice(
159+
key=xcom_arg.key,
160+
dag_id=source.dag_id,
161+
task_id=source.task_id,
162+
run_id=self._ti.run_id,
163+
start=start,
164+
stop=stop,
165+
step=step,
137166
)
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))
167+
168+
@staticmethod
169+
def _slice_values(msg: Any) -> list[T]:
170+
from airflow.sdk.execution_time.comms import XComSequenceSliceResult
171+
from airflow.sdk.execution_time.xcom import XCom
172+
173+
if not isinstance(msg, XComSequenceSliceResult):
174+
raise TypeError(f"Got unexpected response to GetXComSequenceSlice: {msg!r}")
175+
return [XCom.deserialize_value(_XComWrapper(value)) for value in msg.root]
143176

144177
def __len__(self) -> int:
145178
if self._len is None:
@@ -193,54 +226,30 @@ def __getitem__(self, key: int) -> T: ...
193226
def __getitem__(self, key: slice) -> Sequence[T]: ...
194227

195228
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-
)
203229
from airflow.sdk.execution_time.task_runner import SUPERVISOR_COMMS
204-
from airflow.sdk.execution_time.xcom import XCom
205230

206231
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]
232+
return self._slice_values(SUPERVISOR_COMMS.send(self._slice_request(*_coerce_slice(key))))
223233

224234
if not isinstance(key, int):
225235
if (index := getattr(key, "__index__", None)) is not None:
226236
key = index()
227237
raise TypeError(f"Sequence indices must be integers or slices not {type(key).__name__}")
228238

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))
239+
index = _normalize_index(key, len(self)) if key < 0 else key
240+
if not self._holds(index):
241+
msg = SUPERVISOR_COMMS.send(self._slice_request(index, index + XCOM_SEQUENCE_CHUNK_SIZE))
242+
self._hold(index, self._slice_values(msg))
243+
return self._chunk[index - self._chunk_start]
244+
245+
246+
def _normalize_index(index: int, length: int) -> int:
247+
"""Map a negative index onto its position from the start, or raise ``IndexError`` past the front."""
248+
if index < 0:
249+
index += length
250+
if index < 0:
251+
raise IndexError(index)
252+
return index
244253

245254

246255
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)