AIP-104: Task Iteration and Dynamic Task Batching - #62922
Conversation
d8a30b9 to
edad5de
Compare
There was a problem hiding this comment.
Thanks for working on this — excited to see DTI taking shape for Airflow 3.2. I've gone through the full diff and have feedback on the implementation, some are bugs that would crash at runtime, others are design choices worth iterating on.
A few high-level things:
-
No tests. ~700 lines of new production code with zero test coverage. We need tests for
IterableOperator,TaskExecutor,MappedTaskInstance,HybridExecutor,XComIterable,DecoratedDeferredAsyncOperator, and theiterate/iterate_kwargsmethods — covering success, failure, retry, deferral, and edge cases. -
Worker resilience. Since DTI runs N sub-tasks inside a single worker process, we need to think through what happens when that worker dies mid-execution — the scheduler has no record of which sub-tasks completed. Worth documenting the expected behavior and trade-offs here (and whether we want to add checkpointing later).
-
Thread safety. Several shared mutable structures (
contextdict,os.environ) are accessed concurrently from multiple threads without synchronization. This needs to be addressed before merge.
Inline comments below with specifics.
Thanks for pointing this out. As mentioned earlier on Slack, this PR is currently intended as an initial draft to demonstrate the concept and gather early architectural feedback. I agree that proper test coverage is essential before this can move forward. The plan is to add unit tests covering the components you mentioned (IterableOperator, TaskExecutor, MappedTaskInstance, HybridExecutor, XComIterable, DecoratedDeferredAsyncOperator, and the iterate/iterate_kwargs APIs), including scenarios for success, retries, failures, deferral, and edge cases. Once we converge on the architectural direction, I will add the corresponding test suite.
I agree this is an important architectural concern and worth discussing further. The goal of this prototype is to explore a trade-off between observability and scheduling overhead, @ashb and @potiuk mentioned the same remark before. If we try to preserve the same visibility and lifecycle guarantees as Dynamic Task Mapping, we essentially end up re-implementing DTM semantics, which brings back the same scheduler overhead that this approach is trying to avoid. This proposal intentionally explores a different point in that trade-off space: executing iterations within a single task while allowing controlled parallelism. That does mean the scheduler has indeed less visibility (but also less load) into the internal execution units.
Good point — thread safety needs to be handled carefully here. Regarding the task context, my understanding is that operators already receive a per-task context instance, but you're right that when running iterations concurrently we should avoid sharing mutable structures across threads. One possible approach would be to create a shallow or deep copy of the context for each execution unit to ensure isolation. If you have concerns about specific structures (e.g., os.environ or others), I'm happy to address them and introduce appropriate synchronization or isolation mechanisms where needed. |
960438c to
765fcfb
Compare
|
@uranusjr You should also review this PR since it touches several important modules :) |
b11f852 to
9f2c750
Compare
16ec1fc to
3242037
Compare
XComIterable stores per-index results under distinct keys (return_value_0, return_value_1, …) with the same map_index, so slicing or iterating N items issues N separate XCom.get_one calls. The existing GetXComSequenceSlice batch endpoint cannot be reused because it ranges over map_index for a single key — the inverse structure. A new POST endpoint that accepts a list of keys is required; this comment records the constraint so the follow-up PR has the context it needs. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Removed more-itertools dependency from task-sdk
|
Quickest fix: git fetch upstream main && git rebase upstream/main
rm uv.lock && uv lock
git add uv.lock && git rebase --continue
git push --force-with-leaseAutomated nudge — ignore if you're not ready to rebase. This comment is updated in place on future |
54bca83 to
ea9a5ec
Compare
# Conflicts: # task-sdk/src/airflow/sdk/bases/decorator.py # task-sdk/src/airflow/sdk/definitions/mappedoperator.py
|
There’s an asymmetry on I don't think there's a clean fix, so I'd suggest (a) document it as a limitation in the IterableOperator docstring, and (b) emit a clear warning/error when a sync operator with execution_timeout is iterated, rather than relying on the generic per-execution TimeoutPosix warning. A test asserting the async path times out (and pinning the sync behavior) would keep it from silently regressing. |
amoghrajesh
left a comment
There was a problem hiding this comment.
A question about how iteration state survives a retry.
If I'm reading XComIterable correctly, per item results land under return_value_0, return_value_1 and so on with map_index = -1. Does resume after failure read those keys back to skip items that already finished?
If it does, I do not think it can work today. On every task run that is not a deferral resume, the API server collects every xcom key for the task instance and sends them to the worker as xcom_keys_to_clear it. And the task sdk runner deletes them on a task start up.
For a task running for 100 items, with crash on item 60, Attempt 1's return_value_0 through return_value_59 are gone before attempt 2 starts, and the retry repeats all 100 items.
A suggestion: the xcom design for results looks right to me. Downstream tasks have to read them, and that is what XCom is for.
Its the progress record that needs a different home, and task_state_store from AIP-103 is built for exactly this, since rows are scoped per task instance and deliberately survive retries. The AIP-104 wiki page already mentions AIP-103 for intermediate state, so this may just be the implementation catching up with the design.
That split might also shrink #70223. If progress lives in task state and only final results go to XCom, the bulk-key read endpoint may not be needed.
… task iteration yet
Indeed you're correct, that's why I was excited when AIP-103 was announced, as it will solve this issue, but I would like to do this in a separate PR once this PR is merged, otherwise the PR would become too big and it's already very big. Same for the XComIterable optimisation, I also created a dedicated PR to address this issue. |
Sync sub-tasks inside IterableOperator run in worker threads, so TimeoutPosix (SIGALRM) is never delivered to them — their execution_timeout is silently ignored. Async sub-tasks correctly enforce execution_timeout via asyncio.wait_for. Rather than leaving this as a silent surprise, surface it explicitly: add a `.. warning::` block to the IterableOperator docstring describing the limitation and recommending BaseAsyncOperator when timeouts are needed, and emit a UserWarning at construction time when a sync operator with execution_timeout is wrapped. Three tests pin the behaviour: - the existing test is updated to assert the UserWarning is raised; - a new test verifies the async path actually times out via asyncio.wait_for (raises BaseExceptionGroup); - a new test verifies the sync path emits the expected UserWarning mentioning TimeoutPosix. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Replace the isinstance(self.expand_input, BatchedExpandInput) check in IterableOperator._run_tasks with a polymorphic wrap_exceptions() method on ExpandInput. The base class raises a BaseExceptionGroup (existing behaviour for regular iterables); BatchedExpandInput overrides it to raise AirflowFailException so the parent TI is never retried. This removes knowledge of BatchedExpandInput from _run_tasks and keeps the distinction between the two failure modes co-located with the type that drives it, as suggested in code review. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
# Conflicts: # uv.lock
…rror in async tasks get_uri() accesses extra_dejson, which calls the synchronous mask_secret() → comms.send() from within the event-loop thread. Any async hook or task that calls aget_hook() / aget_connection() triggers this path, and Airflow 3.3.1's DeadlockImminentError detection surfaces the bug. Add aextra_dejson() — an async method that awaits amask_secret() instead of the blocking mask_secret() — and aget_uri(), which delegates URI assembly to a new shared _build_uri() helper and calls await self.aextra_dejson(). This keeps the entire connection-serialisation path safely on the async stack. The sync get_uri() / extra_dejson are unchanged; _build_uri() is the single source of truth for the URI format, shared by both paths. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Was generative AI tooling used to co-author this PR?
Github Copilot with Claude Opus 4.6 for some parts like setting up tests or improving documentation.
Description
This PR is the initial implementation of Dynamic Task Iteration (DTI), as discussed in the devlist and building upon the foundations of AIP-104.
For further context on the use cases and performance benefits of DTI, see this Medium Article.
The XCom Database Constraint Challenge
While porting our internal "monkey-patched" version of DTI (used since Airflow 2.x) to the core, I've identified a significant technical hurdle regarding XCom handling.
The Issue
Around Airflow 2.10/2.11, a change was introduced to the database constraints for the XCom table. Specifically:
The drawback is that XCom's wouldn't automatically be removed from the database when a TaskInstance would be deleted, which is the purpose of that constraint. So for DTI it would be a good solution, but not for the DTM.
Current Workaround in this PR
To maintain functionality without immediate schema changes, I have implemented a new XComIterable class. This appends the index directly to the XCom key to bypass the constraint and manages the iteration logic internally.
I believe the cleanest path forward is to add a dedicated route (POST method) in the execution API which would allow to retrieve multiple XCom's related to a TaskInstance with multiple keys, that way there would be less interaction needed between the API server and the XComIterable from the Task SDK.
Examples
Examples
The examples below assume an HTTP connection named
pokeapipointing tohttps://pokeapi.co.Task Iteration
This example fetches a list of Pokémon from the PokéAPI and then uses Dynamic Task Iteration (DTI) to retrieve the details of each Pokémon. A single task instance processes all Pokémon URLs.
Task Iteration with Dynamic Task Batching
This example performs the same work as above, but batches the workload into two concurrent task instances. Each task instance processes approximately half of the Pokémon URLs using Task Iteration.
Comparison
get_pokemon.expand(url=urls)get_pokemon.iterate(url=urls)get_pokemon.batch(size=2).iterate(url=urls)This demonstrates how Task Iteration can significantly reduce TaskInstance creation overhead while still allowing controlled parallelism through batching.
{pr_number}.significant.rstor{issue_number}.significant.rst, in airflow-core/newsfragments.