Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
44 commits
Select commit Hold shift + click to select a range
15effd9
refactor: Implemented buffered next method in LazyXComIterator so tha…
dabla Nov 6, 2025
a7c1240
Merge branch 'main' into feature/prefetch-lazy-xcom-sequence
dabla Nov 6, 2025
d116f99
Merge branch 'main' into feature/prefetch-lazy-xcom-sequence
dabla Nov 6, 2025
c8839ac
Merge branch 'main' into feature/prefetch-lazy-xcom-sequence
dabla Nov 6, 2025
521b112
refactor: Fixed definition of _buffer in LazyXComIterator
dabla Nov 6, 2025
afb417c
Merge branch 'main' into feature/prefetch-lazy-xcom-sequence
dabla Nov 6, 2025
8e01303
Merge branch 'main' into feature/prefetch-lazy-xcom-sequence
dabla Nov 6, 2025
3c0e567
Merge branch 'main' into feature/prefetch-lazy-xcom-sequence
dabla Nov 7, 2025
42f6cb4
Merge branch 'main' into feature/prefetch-lazy-xcom-sequence
dabla Nov 7, 2025
44367f8
Merge branch 'main' into feature/prefetch-lazy-xcom-sequence
dabla Nov 7, 2025
55c1eb8
refactor: Refactored initialisation of buffer in LazyXComIterator
dabla Nov 18, 2025
dbc4d37
Merge branch 'main' into feature/prefetch-lazy-xcom-sequence
dabla Nov 19, 2025
0390a36
Merge branch 'main' into feature/prefetch-lazy-xcom-sequence
dabla Dec 4, 2025
a087bf0
Merge branch 'main' into feature/prefetch-lazy-xcom-sequence
dabla Dec 4, 2025
72dfc4d
Merge branch 'main' into feature/prefetch-lazy-xcom-sequence
dabla Dec 4, 2025
822ed58
Merge branch 'main' into feature/prefetch-lazy-xcom-sequence
dabla Dec 5, 2025
d8013ab
refactor: Reformatted lazy sequence
dabla Dec 5, 2025
b8be901
refactor: Fixed prefetch_size in LazyXComIterator
dabla Dec 5, 2025
ed3856e
Merge branch 'main' into feature/prefetch-lazy-xcom-sequence
dabla Dec 5, 2025
67f41af
Merge branch 'main' into feature/prefetch-lazy-xcom-sequence
dabla Dec 6, 2025
964c34a
Merge branch 'main' into feature/prefetch-lazy-xcom-sequence
dabla Dec 8, 2025
bf7052f
Merge branch 'main' into feature/prefetch-lazy-xcom-sequence
dabla Dec 11, 2025
861bf0b
refactor: Changed import of LoggingMixin from core to sdk
dabla Dec 12, 2025
25d8e08
refactor: Try fixing test_iter test
dabla Dec 12, 2025
18b9a1b
Merge branch 'main' into feature/prefetch-lazy-xcom-sequence
dabla Dec 12, 2025
65270a9
refactor: Try fixing test_iter on lazy_sequence and added test case w…
dabla Dec 12, 2025
7e4694f
refactor: Reordered imports exceptions
dabla Dec 12, 2025
299cf40
refactor: Fixed 2 iter related tests on lazy_sequence
dabla Dec 12, 2025
dca59f3
Merge branch 'main' into feature/prefetch-lazy-xcom-sequence
dabla Dec 12, 2025
af6ec60
Merge branch 'main' into feature/prefetch-lazy-xcom-sequence
dabla Dec 16, 2025
40e3bd7
refactor: Fixed assertion in test_iter of lazy_sequence
dabla Dec 17, 2025
1384e1a
Merge branch 'main' into feature/prefetch-lazy-xcom-sequence
dabla Dec 17, 2025
03a92c2
refactor: Fixed iter tests on lazy_sequence
dabla Dec 17, 2025
76ded73
Merge branch 'main' into feature/prefetch-lazy-xcom-sequence
dabla Dec 17, 2025
849599e
Merge branch 'main' into feature/prefetch-lazy-xcom-sequence
dabla Dec 17, 2025
b66275f
Merge branch 'main' into feature/prefetch-lazy-xcom-sequence
dabla Dec 17, 2025
5968835
Merge branch 'main' into feature/prefetch-lazy-xcom-sequence
dabla Dec 18, 2025
2d4676f
Merge branch 'main' into feature/prefetch-lazy-xcom-sequence
dabla Dec 26, 2025
d8406ce
Merge branch 'main' into feature/prefetch-lazy-xcom-sequence
dabla Jan 5, 2026
7447f9a
Merge branch 'main' into feature/prefetch-lazy-xcom-sequence
dabla Jan 8, 2026
71f076b
Merge branch 'main' into feature/prefetch-lazy-xcom-sequence
dabla Jan 8, 2026
b2a3e1d
Merge branch 'main' into feature/prefetch-lazy-xcom-sequence
dabla Jan 8, 2026
37957a7
Merge branch 'main' into feature/prefetch-lazy-xcom-sequence
dabla Jan 10, 2026
d4588b0
Merge branch 'main' into feature/prefetch-lazy-xcom-sequence
dabla Jan 30, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 25 additions & 6 deletions task-sdk/src/airflow/sdk/execution_time/lazy_sequence.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,12 +19,16 @@

import collections
import itertools
from collections import deque
from collections.abc import Iterator, Sequence
from typing import TYPE_CHECKING, Any, Literal, TypeVar, overload

import attrs
import structlog

from airflow.configuration import conf

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This should import from airflow.sdk instead.

from airflow.sdk.definitions._internal.logging_mixin import LoggingMixin

if TYPE_CHECKING:
from airflow.sdk.definitions.xcom_arg import PlainXComArg
from airflow.sdk.execution_time.task_runner import RuntimeTaskInstance
Expand All @@ -35,25 +39,40 @@
# ``XCom.deserialize_value``. We don't want to wrap the API values in a nested
# {"value": value} dict since it wastes bandwidth.
_XComWrapper = collections.namedtuple("_XComWrapper", "value")

log = structlog.get_logger(logger_name=__name__)


def _deque_factory() -> deque:
return deque(maxlen=conf.getint("core", "parallelism"))
Comment thread
dabla marked this conversation as resolved.


@attrs.define
class LazyXComIterator(Iterator[T]):
class LazyXComIterator(LoggingMixin, Iterator[T]):
seq: LazyXComSequence[T]
index: int = 0
dir: Literal[1, -1] = 1
_buffer: deque[T] = attrs.field(factory=_deque_factory, init=False)

@property
def prefetch_size(self) -> int:
return self._buffer.maxlen or conf.getint("core", "parallelism")

def __next__(self) -> T:
if self.index < 0:
# When iterating backwards, avoid extra HTTP request
raise StopIteration()
try:
val = self.seq[self.index]
except IndexError:
raise StopIteration from None

# If the buffer is empty, fetch the next chunk
if not self._buffer:
chunk = list(self.seq[self.index : self.index + self.prefetch_size])
if not chunk:
raise StopIteration()
self._buffer.extend(chunk)
self.log.debug("Buffered %s XCom's", len(self._buffer))

val = self._buffer.popleft()
self.index += self.dir
self.log.debug("Popped buffered XCom for index %s: %s", self.index, val)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It has of course a drawback of consuming too many of those. I think before we merge it, we need to consider certain scenarios and better way of controlling it so that users can control prefetching.

And of course explain in the docs all kinds of usages.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, and there I have to come back to me above response regarding your why 'core.paralellis' question? I don't see directly how we could for example derive the 'max_active_tis_per_dagrun' parameter from the operator from where the Xcom is being processed, as that would make it already less rigid.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Or we introduce the 'BufferedXComIterator' I posted above, so that you can programmatically decide if you want to do buffered iteration or not. But then it's not out of the box, but you can decide the buffer size used for paging.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I feel it’d be a good idea to have a separate interface. The appropriate value here varies per-task or even per-XCom, so a deployment-wide config wouldn’t be too useful for anyone.

Maybe a very small default would kind of make sense, and if the amount of entries is small enough we could even just send everything regardless, but I feel this should be done more automatically at the server side instead, where more context is available to make a better decision.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I feel it’d be a good idea to have a separate interface. The appropriate value here varies per-task or even per-XCom, so a deployment-wide config wouldn’t be too useful for anyone.

Fully agree on that, I tried to search for better solution but couldn't come up with one that easily

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Maybe a very small default would kind of make sense, and if the amount of entries is small enough we could even just send everything regardless, but I feel this should be done more automatically at the server side instead, where more context is available to make a better decision.

Don't see how you could do that on server side, as it is the client that determines how many iterations it will do, and thus how many XCom's it wants to retrieve, that just the part I'm struggling with.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

How about a kwarg on BaseOperator?

return val

def __iter__(self) -> Iterator[T]:
Expand Down
89 changes: 65 additions & 24 deletions task-sdk/tests/task_sdk/execution_time/test_lazy_sequence.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@
import pytest

import airflow
from airflow.configuration import conf
from airflow.sdk.bases.xcom import BaseXCom
from airflow.sdk.exceptions import ErrorType
from airflow.sdk.execution_time.comms import (
Expand Down Expand Up @@ -74,35 +75,75 @@ def test_len(mock_supervisor_comms, lazy_sequence):


def test_iter(mock_supervisor_comms, lazy_sequence):
stop = conf.getint("core", "parallelism")

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Instead of reading the conf, use @conf_vars to patch (and also test more than one value).

it = iter(lazy_sequence)

mock_supervisor_comms.send.side_effect = [
XComSequenceIndexResult(root="f"),
ErrorResponse(error=ErrorType.XCOM_NOT_FOUND, detail={"oops": "sorry!"}),
XComSequenceSliceResult(root=["f"]),
]
assert list(it) == ["f"]
mock_supervisor_comms.send.assert_has_calls(
[
call(
msg=GetXComSequenceItem(
key=BaseXCom.XCOM_RETURN_KEY,
dag_id="dag",
task_id="task",
run_id="run",
offset=0,
),
),
call(
msg=GetXComSequenceItem(
key=BaseXCom.XCOM_RETURN_KEY,
dag_id="dag",
task_id="task",
run_id="run",
offset=1,
),
),
]
)

assert mock_supervisor_comms.send.call_args_list == [
call(
GetXComSequenceSlice(
key=BaseXCom.XCOM_RETURN_KEY,
dag_id="dag",
task_id="task",
run_id="run",
start=0,
stop=stop,
step=None,
)
),
call(
GetXComSequenceSlice(
key=BaseXCom.XCOM_RETURN_KEY,
dag_id="dag",
task_id="task",
run_id="run",
start=1,
stop=stop + 1,
step=None,
)
),
]


def test_iter_when_xcom_not_found(mock_supervisor_comms, lazy_sequence):
stop = conf.getint("core", "parallelism")
it = iter(lazy_sequence)

mock_supervisor_comms.send.side_effect = [
XComSequenceSliceResult(root=["f"]),
ErrorResponse(error=ErrorType.XCOM_NOT_FOUND, detail={"oops": "sorry!"}),
]
with pytest.raises(TypeError):
list(it)

assert mock_supervisor_comms.send.call_args_list == [
call(
GetXComSequenceSlice(
key=BaseXCom.XCOM_RETURN_KEY,
dag_id="dag",
task_id="task",
run_id="run",
start=0,
stop=stop,
step=None,
)
),
call(
GetXComSequenceSlice(
key=BaseXCom.XCOM_RETURN_KEY,
dag_id="dag",
task_id="task",
run_id="run",
start=1,
stop=stop + 1,
step=None,
)
),
]


def test_getitem_index(mock_supervisor_comms, lazy_sequence):
Expand Down
Loading