dabla commented on code in PR #62922:
URL: https://github.com/apache/airflow/pull/62922#discussion_r4252539275


##########
task-sdk/src/airflow/sdk/bases/xcom.py:
##########
@@ -566,3 +569,174 @@ def delete(
             ),
         )
         cls.purge(xcom_result)
+
+
+class XComIterable(Sequence):
+    """
+    An iterable that lazily fetches XCom values one by one instead of loading 
all at once.
+
+    This is a read-only :class:`collections.abc.Sequence` over the 
``return_value_<index>`` XComs an
+    iterated task pushed, one per index: the values are written by the 
producing task's runner as
+    each sub-task finishes (see ``IterableOperator.axcom_push``), and the 
iterable only ever reads
+    them. Nothing on this class mutates the underlying XComs.
+
+    Indexing follows the usual sequence rules, negative indices included: 
``result[-1]`` is the last
+    value. Iterations that were skipped pushed nothing and are left out, as 
the XComs of skipped
+    mapped task instances are: ``length`` counts every input item, ``skipped`` 
lists the indices
+    that were skipped, and positions in the sequence run over the others only. 
An iteration that
+    returned ``None`` pushed nothing either but keeps its position: reading it 
gives ``None``,
+    where ``.expand()`` would not count such a mapped task instance at all.
+
+    Every element is a remote fetch, so random access costs one XCom read per 
element, and so do
+    iterating and slicing: N elements are N requests. Reading several in one 
request needs an
+    Execution API endpoint that takes a list of keys, which does not exist yet.
+
+    Inside a loop, the result an iterated terminal pushed in the previous 
iteration
+    (``loop.previous``) has ``previous_iteration`` set, so its elements are 
read from that
+    iteration too and not from the current one.
+    """
+
+    def __init__(
+        self,
+        task_id: str,
+        dag_id: str,
+        run_id: str,
+        map_index: int | None = None,
+        length: int | None = None,
+        skipped: Sequence[int] = (),
+        previous_iteration: bool = False,
+    ):
+        self.task_id = task_id
+        self.dag_id = dag_id
+        self.run_id = run_id
+        self.map_index = map_index
+        self.length = length or 0
+        self.skipped: list[int] = sorted(skipped)
+        self.previous_iteration = previous_iteration
+
+    def _index_of(self, position: int) -> int:
+        """Map ``position`` in the sequence to its input index, stepping over 
the skipped indices."""
+        # range() maps a negative position from the end and raises IndexError 
past either end.
+        index = range(len(self))[position]
+        for skipped_index in self.skipped:
+            if skipped_index > index:
+                break
+            index += 1
+        return index
+
+    def __iter__(self) -> Iterator[Any]:
+        return _XComIterator(self)
+
+    def __len__(self) -> int:
+        return self.length - len(self.skipped)
+
+    async def alen(self) -> int:
+        """Async twin of ``len(self)``, for readers on the event loop that 
take a length before each read."""
+        return len(self)
+
+    def __getitem__(self, key: int | slice) -> Any | Sequence[Any]:
+        """Allow direct indexing so this works like a sequence."""
+        from airflow.sdk.execution_time.xcom import XCom
+
+        if isinstance(key, slice):
+            # TODO: This issues one XCom.get_one call per element — N 
round-trips for a full slice.
+            # XComIterable stores results under distinct keys (return_value_0, 
return_value_1, …)
+            # with the same map_index, so the existing GetXComSequenceSlice 
endpoint (which ranges
+            # over map_index for a single key) cannot be reused.  A new POST 
endpoint that accepts
+            # a list of keys and returns values in a single query is needed; 
once that lands, replace
+            # this loop with a single batched fetch.
+            start, stop, step = key.indices(len(self))
+            return [self[i] for i in range(start, stop, step)]
+
+        return XCom.get_one(**self._item_query(key))
+
+    async def aget(self, index: int) -> Any:
+        """
+        Async counterpart of ``self[index]``: fetch one value through 
``XCom.aget_one``.
+
+        Use it, or ``async for``, from code running on an event loop that has 
other SDK calls in
+        flight (an iterated task consuming this iterable as its input): a 
synchronous read there
+        would block the loop thread on the supervisor channel and deadlock 
with them.
+        """
+        from airflow.sdk.execution_time.xcom import XCom
+
+        return await XCom.aget_one(**self._item_query(index))
+
+    def __aiter__(self) -> AsyncIterator[Any]:
+        return _AsyncXComIterator(self)
+
+    def _item_query(self, position: int) -> dict[str, Any]:
+        """
+        Build the arguments of the XCom read for the item at ``position``.
+
+        The previous-iteration flag is passed only when set, so a read outside 
a loop calls the
+        XCom backend's ``get_one``/``aget_one`` with the same arguments as 
before.
+        """
+        query: dict[str, Any] = {
+            "key": f"{BaseXCom.XCOM_RETURN_KEY}_{self._index_of(position)}",
+            "dag_id": self.dag_id,
+            "task_id": self.task_id,
+            "run_id": self.run_id,
+            "map_index": self.map_index,
+        }
+        if self.previous_iteration:
+            query["_previous_iteration"] = True
+        return query
+
+    def serialize(self) -> dict:
+        """Ensure the object is JSON serializable."""
+        data = {
+            "task_id": self.task_id,
+            "dag_id": self.dag_id,
+            "run_id": self.run_id,
+            "map_index": self.map_index,
+            "length": self.length,
+            "skipped": self.skipped,
+        }
+        if self.previous_iteration:
+            # Only when set, so a value pushed outside a loop keeps its 
earlier form.
+            data["previous_iteration"] = True

Review Comment:
   Good question: it is not supported, and it now says so instead of reading 
the wrong items (d6d5844266). "Previous" is resolved against the reader's 
iteration on the server, so a `loop.previous` result pushed on unchanged points 
every later reader, in the next iteration or after the loop, at another 
iteration's items. `XComIterable.serialize()` now refuses an iterable with 
`previous_iteration` set with a `TypeError` that says to push the values 
instead (`list(loop.previous)`), and the flag is no longer written into the 
pushed form. serde goes through it for both an XCom push and an item's 
checkpoint, so both refuse it the same way; reading through `loop.previous` is 
unchanged.
   
   I looked at the two alternatives. Materializing at push, as the 
`LazyXComSequence` serializer does for a mapped terminal, would store a 
previous result of any size as one XCom, which `XComIterable` is there to 
avoid. Pinning the iteration, so the pointer stays lazy and right, needs an 
XCom read by absolute iteration in the Execution API; that is an API change I 
would rather put in a stacked PR of its own than in this one.
   
   Drafted-by: Claude Opus 5.5; reviewed by @dabla before posting
   



-- 
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.

To unsubscribe, e-mail: [email protected]

For queries about this service, please contact Infrastructure at:
[email protected]

Reply via email to