Skip to content

AIP-104: Task Spreading - #73688

Draft
dabla wants to merge 24 commits into
apache:mainfrom
dabla:feature/task-spreading
Draft

dabla wants to merge 24 commits into
apache:mainfrom
dabla:feature/task-spreading

Conversation

@dabla

@dabla dabla commented Sep 24, 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.


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

Claude Code (Fable 5.1).

Description

This PR adds Task Spreading from AIP-104 on top of Iterable Tasks from #62922: .batch(size=N).iterate(...) spreads one iteration over exactly N task instances through Dynamic Task Mapping, and each task instance iterates over its round-robin share with Iterable Tasks. It is the second half of the original #62922, split out so that PR stays focused on .iterate().

Stacked on #62922. Until that PR merges, the diff shown here includes it. The spreading work is the last commit, "Add task batching on top of iterable tasks". The method is still called .batch() in the code; see Open points for the rename to .spread(). To review only the spreading layer, use the compare view of this branch against #62922's branch: dabla/airflow@feature/dynamic-task-iteration...feature/task-spreading (GitHub's native stacked PRs cannot be used here, since they require all branches in the same repository and the branches live in a fork). Once #62922 merges, this branch is rebased onto main and the diff here collapses to that layer.

What this PR adds

  • .batch(size=...) on OperatorPartial and on @task returns a BatchedOperator / DecoratedBatchedOperator (airflow.sdk.definitions.batchedoperator). Their iterate() / iterate_kwargs() reuse the partial's own input validation and _expand(), and build a MappedIterableOperator instead of an IterableOperator.
  • MappedIterableOperator: a MappedOperator whose task instances each run an IterableOperator over their share of the input. BatchedExpandInput routes item i to task instance i % size, so exactly size instances are created regardless of how many items the (possibly unbounded or paginated) input yields.
  • Runtime size: size may be an XComArg, the return value of a plain, non-mapped task. The scheduler never reads the XCom value: the worker pushes the integer as the mapped_length of that push and the scheduler counts instances from that column (SerializedMappedOperator.resolve_batch_size), raising NotFullyPopulated until the upstream has run. The value must be an integer of at least 2 and at most core.max_map_length; .map()/.zip() results, pushed keys and mapped upstreams are rejected at parse time.
  • Scheduler-side counting (get_parse_time_mapped_ti_count / get_mapped_ti_count) for spread tasks, the docs section Combining DTM and IT (Batched Task Mapping), and tests for all of the above.

Example

Task Iteration with Task Spreading

This example performs the same work as the Task Iteration example in #62922, but spreads the workload over two concurrent task instances. Each task instance processes roughly half of the Pokémon URLs using Task Iteration.

from airflow.sdk import dag, task
from airflow.providers.http.hooks.http import HttpHook, HttpAsyncHook

from pendulum import datetime

@dag(
    start_date=datetime(2025, 1, 1),
    schedule=None,
    catchup=False,
)
def pokemon_spread_iteration():
    @task
    def list_pokemon() -> list[str]:
        response = HttpHook(
            http_conn_id="pokeapi",
            method="GET",
        ).run(
            endpoint="api/v2/pokemon?limit=100",
        )

        return [
            pokemon["url"].replace("https://pokeapi.co/", "")
            for pokemon in response.json()["results"]
        ]

    @task(
        retries=3,
        task_concurrency=2,
        show_return_value_in_logs=False,
    )
    async def get_pokemon(url: str):
        async with HttpAsyncHook(
            http_conn_id="pokeapi",
            method="GET",
        ).session() as session:
            response = await session.run(endpoint=url)
            return await response.json()

    get_pokemon.batch(size=2).iterate(
        url=list_pokemon(),
    )

pokemon_spread_iteration()
Pokemon.Partitioned.Task.Iteration.mp4

Comparison

Pattern Task Instances Work Per Task
get_pokemon.expand(url=urls) 100 1 Pokémon
get_pokemon.iterate(url=urls) 1 100 Pokémon
get_pokemon.batch(size=2).iterate(url=urls) 2 ~50 Pokémon each

Task Spreading keeps the reduced TaskInstance overhead of Task Iteration while allowing controlled parallelism across workers.

Note: batch(size=2) creates two task instances, each processing its assigned items with Task Iteration. size is the number of task instances, not a chunk length: items are distributed round-robin, unlike itertools.batched, because the instance count must be fixed before a possibly unbounded input is consumed.

Open points

  • Naming. The method is still .batch(size=N) in the code. The devlist vote leans towards .spread(), which describes it better: exactly N task instances with the items dealt round-robin, not itertools.batched-style chunks. The rename lands in this PR.
  • Chunking is a different feature. Handing each mapped task instance a list of items from a known input (for example 100 S3 keys as 10 lists of 10, for an operator that accepts a list) is a transformation of the upstream value and belongs on XComArg next to .map() and .zip(), e.g. .expand(keys=files.batch(10)). It needs no iteration and is not part of this PR.

🤖 Generated with Claude Code

This was referenced Sep 24, 2026
@dabla
dabla requested review from kaxil and uranusjr September 25, 2026 07:06
@dabla dabla self-assigned this Sep 25, 2026
@dabla dabla removed the backport-to-v3-3-test Backport to v3-3-test label Sep 25, 2026
@dabla
dabla force-pushed the feature/task-spreading branch 2 times, most recently from 2c2a17d to f3ed32f Compare September 27, 2026 12:17
@dabla

dabla commented Sep 27, 2026

Copy link
Copy Markdown
Contributor Author

Rebased on the reworked #62922 (see the comment there: .iterate() now resolves its input by index like .expand()). Still a single commit on top of it, f3ed32f.

What the rework changed here: BatchedExpandInput.aresolve() is a range(map_index, length, size) over the delegate instead of walking the whole input and skipping; the striding tests moved to aresolve(); and the round-robin note in the docs no longer argues from a length that is "unknowable until drained", since the count is fixed by the scheduler before the task runs, which is the reason that actually holds.

@dabla
dabla force-pushed the feature/task-spreading branch from f3ed32f to 5a96cbf Compare September 27, 2026 12:41
@dabla

dabla commented Sep 27, 2026

Copy link
Copy Markdown
Contributor Author

Restacked on the rebased #62922; still one commit, 5a96cbf.

@dabla
dabla force-pushed the feature/task-spreading branch from 5a96cbf to 488139d Compare September 27, 2026 17:32
@dabla

dabla commented Sep 27, 2026

Copy link
Copy Markdown
Contributor Author

Restacked on #62922 after XComIterable.flatten() moved out to its own PR; still one commit, 488139d.

@dabla
dabla force-pushed the feature/task-spreading branch 13 times, most recently from cc06223 to 97a8c3a Compare October 1, 2026 10:19
dabla and others added 22 commits October 1, 2026 12:35
Add Iterable Tasks: `.iterate()` and `.iterate_kwargs()` on operators and
`@task`, the counterpart of `.expand()` that processes every item inside
one task instance instead of creating one task instance per item.

- IterableOperator resolves the input by index as `.expand()` does and
  runs the items on AsyncAwareExecutor: sync operators in a thread pool,
  async operators concurrently on one event loop, up to
  `task_concurrency` at a time.
- Each item's return value is pushed as `return_value_<index>`, and the
  task returns an XComIterable, a lazy read-only Sequence over them that
  a downstream `.expand()` or `.iterate()` consumes. Skipped items are
  left out, and downstream tasks with `all_success` are skipped, as with
  a mapped upstream.
- Per-item progress is checkpointed in the task state store (AIP-103),
  tied to the item's input and the attempt that wrote it, so a retry or a
  clear after a failure resumes the items that already succeeded and a
  clear after success runs them all again. Outlet events are replayed
  from the checkpoint.
- XComs and task state written from an item carry its index, and each
  item runs against its own view of the context.
- Deferral, reschedule-mode sensors, TriggerDagRunOperator and
  downstream skipping from an item are rejected with a clear error.
- Documented in task-sdk/docs/dynamic-task-mapping-vs-iteration.rst.

Co-Authored-By: Tzu-ping Chung <uranusjr@gmail.com>
Co-Authored-By: Copilot <223556219+Copilot@users.noreply.github.com>
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The error an iterated async task gets for a synchronous SDK call already
points at Variable.aget/aset, which apache#72329 adds; the class docstring
still said Variable had no async equivalent.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
partial() already prefixes the wrapped operator's task id with the task
group, and BaseOperator.__init__ prefixed it again for the
IterableOperator, so inside a TaskGroup the task was registered as
"tg.tg.f" while its items pushed their XComs for "tg.f", a task id with
no task instance. The IterableOperator now gets the bare id, and an
iteration takes the task id of the task instance that runs it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
BaseOperator.__deepcopy__ calls copy.copy on every attribute in
shallow_copy_attrs, which held the lock guarding the sub-tasks in
flight, and a lock cannot be copied: deepcopy of an iterated task and
dag.partial_subset() failed on any Dag using .iterate(). A copy is
another task with nothing in flight, so it now gets a fresh lock and an
empty set through the deepcopy memo.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The execution timeout unwinds through the executor, which cancels every
item's coroutine, and each one left the register of operators in flight
before the runner called on_kill(); sync items too, since they were
registered in the coroutine rather than in the thread still running
execute. The register was also a set, and the sub-operators of one
iterated task compare equal, so it never held more than one of them.
on_kill() now runs before the executor cancels, operators are
registered where their code runs and keyed by identity, the item the
timeout strikes directly is killed as it unwinds, and each is killed
once although the runner calls on_kill() again.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
create_indexed_task built the indexed task instance without the
parent's _ti_context_from_server, so every iteration had no logical
date, a template context without dag_run or ds, and get_previous_ti()
and get_previous_dagrun() answered for no run at all.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Every item failure reached the runner inside a BaseExceptionGroup, and
the runner decides by exception type: a retry_policy rule never matched
the group, and AirflowSensorTimeout, which the runner fails without a
retry, was retried. Fail-fast exceptions are now raised on their own, a
single failure unwrapped, and with several failures the retry policy is
evaluated on each item's exception and the one whose decision weighs
most is raised, the others attached as its cause. IndexedTaskRunner
treats the fail-fast exceptions as final for the callbacks too.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
An iteration is unmapped from the MappedOperator, whose downstream task
ids are empty because the edges land on the IterableOperator, so a
ShortCircuitOperator took its "no downstream tasks" early return and a
branch operator found nothing to skip: every downstream task ran.
.iterate() now refuses any SkipMixin operator when the Dag is defined.
The check is on the class, since the @task path's
can_skip_downstream is False even for @task.short_circuit and
@task.branch.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The SUCCESS checkpoint was written before the result was pushed to XCom,
and a failing push fell through to the handler that overwrites it with
UP_FOR_RETRY, so the retry ran the operator again for work that had
finished. Publishing now fails on its own: the checkpoint stays, the
task retries, and the retry replays the result from the checkpoint.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The runner deletes every XCom of the task before an attempt, and a
retry that skips an item which already succeeded replayed only its
return value and outlet events, so any other key it pushed was lost
although the task then succeeded. The keys an item pushes are now kept
in memory while it runs, written once with its SUCCESS checkpoint and
pushed again when a retry skips it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A checkpoint was honoured when the item's iterated kwargs matched, but
a .partial() kwarg can come from an upstream task too: after clearing
that upstream together with a partly failed task, the items that had
succeeded were replayed with the old value while the others ran with
the new one. The fingerprint is now taken once the item is rendered
and includes the resolved values of partial kwargs that are XComArgs.
Other templated partial values stay out of it, since one that changes
with every attempt would make every checkpoint look stale.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Each item decided on its own whether it would be retried, so an item
could announce a retry the task never got: after a sibling's
AirflowFailException, or for an item _run_tasks rejects. A failure is
now only noted when the item exits, and once every item has run each
failed one gets the callback matching what happens to the task:
on_retry_callback when it is retried, on_failure_callback when not.
Success and skip callbacks still fire right away. The docs say which
callbacks run when, and that a failure no item owns fires none.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…l threads

InProcessSupervisorComms served a request by removing
task_runner.SUPERVISOR_COMMS from the whole process, so that the code
serving it acted as the server side, and matched answers to requests by
their order only. Items of an iterated task calling it from worker
threads failed with ImportError or took each other's answers. The comms
are now hidden from the serving thread alone, through a per-thread flag
that models.Variable, models.Connection, mask forwarding and the secrets
backend choice read via task_runner.supervisor_comms() (in airflow-core
through airflow.utils.helpers.in_task_execution_context()), and a lock
serves one request at a time from handling it to taking its answer.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
.iterate() over an empty input ran nothing, pushed an empty XComIterable
and succeeded, so an all_success downstream task ran, where .expand()
over the same input is skipped along with it. The IterableOperator now
raises AirflowSkipException when no item comes through.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The IterableOperator reported the wrapped operator's task_type while its
module stayed its own, so what resolves a class from the two got one
that does not run it: OpenLineage picked the wrapped operator's
extractor, which failed on the IterableOperator and dropped the declared
outlets, and the serialized class reference named a class that does not
exist. operator_name is still forwarded, so the task is shown as the
wrapped operator.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@task is typed as returning Task, which declared partial, expand,
expand_kwargs and override only, so .iterate() and .iterate_kwargs() on
a decorated callable were attribute errors under mypy and pyright.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The second iteration found the event already set, read its context and
left its block before the first resumed, so the reads happened in
nesting order and a thread-local stack in place of the ContextVar still
passed. Each iteration now reads while the other is inside its block,
and neither leaves before both have read.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A threading.Thread an iterated task starts, or loop.run_in_executor(),
does not inherit the iteration's context, so get_current_context() there
returns the task's own, whose keys carry no index. The docs now point at
the ti passed to the task, asyncio.to_thread() and
contextvars.copy_context().run(), and a test pins each case.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…connections

Threads overlap blocking I/O up to task_concurrency, async operators
scale further, and CPU-bound code speeds up with neither; the page said
the reverse. The benchmark rows are loops written by hand in one @task
and are now labelled so, a leftover "5 Pokémon" is gone, and iterations
do not share a connection.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Follows the soft rename of "Dynamic Task Mapping" to mapped tasks: the
page, its label and the link to it, and the DTM wording throughout.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
wait_for raises asyncio.TimeoutError, which is the built-in TimeoutError
only from Python 3.11 on, so the two tests expecting the built-in one
failed on 3.10. The runner already catches asyncio's.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…uest

The in-process API server of dag.test() answers on its own threads, its
event loop and a worker thread for sync routes, not on the thread that
sent the request. The per-thread flag left the comms visible there, so a
route reading models.Variable or models.Connection sent a request of its
own and waited for the lock the sender held, which hung every provider
test that runs a task under dag_maker. The flag is now process-wide,
set and restored while that lock is held.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@dabla
dabla force-pushed the feature/task-spreading branch from 97a8c3a to 276f87b Compare October 1, 2026 10:56
dabla and others added 2 commits October 1, 2026 16:19
…quest

The process-wide flag hid the comms from every thread of the task while
a request was served, so under dag.test() a sibling item's Variable or
Connection lookup took the fallback secrets backends and missed values
stored in the metadata database. A per-thread flag on the sender could
not work either: a2wsgi starts the request on the in-process API's event
loop, which never ran on the sender's thread.

The flag is now a ContextVar, set by the sender around _handle_request
and by InProcessExecutionAPI around each request it serves. The event
loop task and the worker threads of sync routes inherit it from there,
and the task's other threads keep their comms.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
.batch(size=N).iterate(...) spreads one iteration over N task instances
through dynamic task mapping, each iterating its round-robin share with
Iterable Tasks. This restores the batching half of the original apache#62922 on top
of the iterate-only branch: BatchedOperator and DecoratedBatchedOperator
(returned by the new .batch() on OperatorPartial and @task), the
MappedIterableOperator and BatchedExpandInput, the runtime batch size resolved
by the scheduler from the size task's mapped_length, the docs section and the
tests.

Unlike before, the batched classes build on the partial's own iterate machinery
instead of the partial funnelling through them: they reuse the partial's input
validators (_iterate_input / _iterate_kwargs_input) and _expand(), so there is
no size=0 sentinel any more.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
@dabla
dabla force-pushed the feature/task-spreading branch from 276f87b to acec154 Compare October 1, 2026 14:59

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