3838
3939log = 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
4347class LazyXComIterator (Iterator [T ]):
@@ -87,9 +91,21 @@ async def __anext__(self) -> T:
8791
8892@attrs .define
8993class 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
246255def _coerce_slice_index (value : Any ) -> int | None :
0 commit comments