Skip to content

Add XComIterable.flatten() to read an iterated task's pages as one sequence - #73807

Draft
dabla wants to merge 52 commits into
apache:mainfrom
dabla:feature/xcom-iterable-flatten
Draft

dabla wants to merge 52 commits into
apache:mainfrom
dabla:feature/xcom-iterable-flatten

Conversation

@dabla

@dabla dabla commented Sep 27, 2026 •

Copy link
Copy Markdown
Contributor

Review only this change: the PR's own commit · diff against #62922's branch. Everything else in this PR's commit list and file list is #62922, which this PR is stacked on.

Stacked on #62922 (Task Iteration): only the last commit, c490ee3, belongs to this PR. It will be rebased once #62922 merges.

related: #62922

What

An iterated task whose iterations each return a page of items, for example one list of rows per API call, pushes one XCom per iteration and returns the XComIterable over them. .flatten() on that output gives a downstream task those pages as one Sequence of items:

pages = fetch_page.iterate(page=range(10))  # each iteration returns a list of rows
load_rows(pages.output.flatten())  # one sequence of rows, page boundaries gone

Nested lists, tuples and sets are expanded recursively; strings, bytes and scalars stay single items.

How

  • FlattenedXComIterable speaks in flattened positions. Its length is the flattened_length that IterableOperator.axcom_push tallies from each value as it is pushed, while the value is in memory, so the view knows its length without reading a page. An XComIterable pushed without the tally is counted by reading its pages once.
  • Reads hold the last page fetched and nothing more: a sequential read (iteration, a slice, an iterated task consuming this as its input) fetches every page exactly once, a read within the held page costs nothing, and a jump backwards restarts from the first page. Memory stays bounded by one page.
  • The same cursor serves __getitem__/__iter__ (blocking reads) and aget/__aiter__ (reads through asend, safe on the task's event loop).

Why a separate PR

This was part of #62922, but task iteration does not need it: nothing in the SDK outside bases/xcom.py referred to it, and it had no documentation there. Splitting it out keeps #62922 about running a task over its input, and gives flatten its own review and its own paragraph in the task-sdk docs, included here.

Tests

test_xcom.py covers expansion of lists, tuples, sets, generators and mixed pages, string/bytes/scalar pass-through, negative indices on both paths, the tallied length round-tripping through serde, one page read per page on a sequential read, the no-tally counting pass, and the one-page memory bound. test_serde.py round-trips the flattened view.


Was generative AI tooling used to co-author this PR?
  • Yes (please specify the tool below)

Generated-by: Claude Code following the guidelines

🤖 Generated with Claude Code

@dabla

dabla commented Sep 27, 2026

Copy link
Copy Markdown
Contributor Author

Interaction with #70223 to handle when both land: FlattenedXComIterable inherits __iter__ from XComIterable. Today that iterates by index through __getitem__, so the flattened view yields flattened items. #70223 changes XComIterable.__iter__ to fetch every raw page with one GetXComByKeys request, so once it is in, FlattenedXComIterable must define its own __iter__ over its flattened positions (self[i] for i in range(len(self))), or iterating it would yield the raw pages. Whichever of the two merges second picks this up.

@dabla
dabla force-pushed the feature/xcom-iterable-flatten branch 15 times, most recently from e1ca970 to bfa2dc4 Compare October 2, 2026 07:52
@dabla
dabla force-pushed the feature/xcom-iterable-flatten branch 4 times, most recently from 772416a to 980397c Compare October 8, 2026 18:15
dabla added 29 commits October 12, 2026 07:31
The success and skip callbacks fired from IndexedTaskRunner.__exit__, as
soon as the operator returned and before the indexed task's checkpoint and
result were written. A checkpoint write that failed had already announced
a success, and the retry ran the indexed task again and announced it a
second time; the callback also found no end_date on the task instance. A
plain task fires its callback after the push and the state report, with
end_date set.

The exit now only notes a failure. IterableOperator._run_task calls the
runner's report_success() or report_skip() once the matching checkpoint is
written, where the indexed task's code ran (its worker thread for a sync
operator, the loop for an async one); both set end_date and the state
before the callback. A result push that fails after the checkpoint is
replayed from it, so the callback fires once. The docs page and the class
docstring say so.

test_the_success_callback_waits_for_the_checkpoint and the two runner
report tests fail before.
…valuating the policy again

With several failed indexed tasks whose retry policy decisions were all the
default, or whose evaluation raised, no exception outweighed the others, the
group was handed to the runner and no decision was kept for it. The callbacks
then evaluated the policy once more, on the BaseExceptionGroup rather than on
any indexed task's exception: K+1 evaluations instead of K, and for a policy
that answers differently per call a retry announced here that the runner's own
evaluation of the group could refuse. The once-per-item test did not reach this
path because its policy answers retry(), so an exception was always chosen.

_failure_for_the_runner now keeps the default decision for the group when no
decision outweighs it, so _task_will_retry follows it and falls back to retry
eligibility without evaluating the policy again.

test_an_undecided_group_is_not_evaluated_again_for_the_callbacks fails before
with four evaluations.
…the iteration

on_kill() reaches the sub-operators that have started, and the stop flag keeps
the executor from pulling more. An indexed task instance is neither until its
runner starts: on a retry attempt it first reads its checkpoint, and a kill that
lands during that read found it unregistered, so it started afterwards, ran to
completion unkilled, its remote work included, and the drain waited for it
before the task could conclude as terminated.

IterableOperator._run_task now asks the iteration state whether a stop was
requested once the checkpoint replay is done and before the runner is built,
and returns IndexedTaskInstanceNotStarted as the outcome: no code run, no
checkpoint, no callback, and the next attempt runs it. IndexedTaskOutcomes
counts these apart from the indexed tasks that ran and the kill message names
them. A sync indexed task is still handed to the pool afterwards, whose threads
are as many as the calls in flight, so only that pickup remains. The docs page
and the class docstring list it with the other outcomes that fire no callback.

test_an_item_pulled_before_the_kill_but_not_started_does_not_run fails before
with two of three indexed tasks run; the outcomes unit test covers the count.
The static checks fail on D401 for IndexedTaskInstance.context_for: its
summary line named what the method returns instead of saying what it does.
The message claimed "the rest never pulled" also when every item had been
pulled, and said the input was being resolved for a kill that landed before
the run started, where nothing was. IndexedTaskOutcomes._killed_message now
names the items that ran, those pulled before the kill and never started and
those never pulled, each only when the count is not zero, and a kill before
the input was resolved says so.
test_a_kill_after_every_item_was_pulled_claims_no_remainder fails before.
The Source docstring still described a scalar as read as a one-item
sequence, which from_argument stopped doing when literal scalars were
refused at parse time.
The IterableOperator docstring carries both warnings, but the task SDK
docs do not render the API reference, so the page is the only place
users read them. The async calls get a sync-to-async lookup table, the
comparison table gains rows for SDK calls and pools, and the warnings
after the example now come before the notes.
An item's own AirflowTaskTimeout next to another item's failure reaches
the runner as a BaseExceptionGroup, which it retries like any failure,
but the failed items' callbacks reported a failure because the group is
not an Exception. A group now counts as retryable when each of its
members is.
Cancelling an asend parked on its response released the thread lock
while the read thread was still in recv, or had not started, so the
next send could read the cancelled request's response as its own. The
request now runs in one worker thread that takes the lock, writes,
reads and releases it; a request abandoned before it was written is
dropped. This replaces the acquire hand-off, and takes one thread hop
per request instead of two.
A .map() callable and the template rendering ran while the executor
pulled the next item, so what they raised ended the whole iteration: a
skip skipped the task, and a failure stopped every later element on
each retry. Each item now reads its own input inside its run, so what
reading it raises is that item's outcome, as under .expand().
xcom_arg now imports the loop module, which imports BaseOperator, so a
module-level import of XComArg in expandinput closes an import cycle
through mappedoperator and fails airflow.sdk on import. The module keeps
it under TYPE_CHECKING and imports it where it is used, as main does.
An iteration shares the parent's identity, but its region was left to
the defaults, so inside a loop it reported region 0 and its own map
index instead of the parent's region coordinates.
…ence

Inside a loop, a lazy XCom sequence can stand for the previous
iteration's values, and its synchronous reads ask for them, but the
async item read and count left the flag out, so an async consumer got
the current iteration's values instead.
The request frame is a bytearray, so the exchange helpers take bytes or
bytearray; and the parent task instance is typed by a protocol without
the region fields, so they are read with getattr, as the server context
next to them is.
…quence

An iterated task whose iterations each return a page of items (a list of rows
per API call) pushes one XCom per iteration and returns the XComIterable over
them. flatten() gives the downstream task those pages as one Sequence of items:
nested lists, tuples and sets are expanded recursively, strings, bytes and
scalars stay single items.

FlattenedXComIterable speaks in flattened positions. Its length is the
flattened_length that IterableOperator.axcom_push tallies from each value as
it is pushed, so the view knows it without reading a page; an XComIterable
pushed without the tally is counted by reading its pages once. Reads hold the
last page fetched and nothing more: a sequential read fetches every page
exactly once, a read within the held page costs nothing, and a jump backwards
restarts from the first page, so memory stays bounded by one page.

Split out of the task iteration PR, which does not need it, so it can be
reviewed and documented on its own.
@dabla
dabla force-pushed the feature/xcom-iterable-flatten branch from c82f05a to 64abc2c Compare October 12, 2026 05:35

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant