2525import attrs
2626import structlog
2727
28+ from airflow .sdk .configuration import conf
29+
2830if TYPE_CHECKING :
2931 from airflow .sdk .definitions .xcom_arg import PlainXComArg
3032 from airflow .sdk .execution_time .task_runner import RuntimeTaskInstance
3840
3941log = 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
4354class LazyXComIterator (Iterator [T ]):
@@ -87,9 +98,22 @@ async def __anext__(self) -> T:
8798
8899@attrs .define
89100class 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
246263def _coerce_slice_index (value : Any ) -> int | None :
0 commit comments