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,10 +98,23 @@ 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" )
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
252260def _coerce_slice_index (value : Any ) -> int | None :
0 commit comments