Repository navigation
AIP-104: Task Iteration - #62922
AIP-104: Task Iteration#62922dabla wants to merge 25 commits into
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
|
Level with |
|
Can you squash commits please? |
Squashed commit like you asked, all current commits are related to each comment of the last review round. |
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>
…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>
The wrapper is typed with Starlette's ASGI types, which mypy cannot match to a2wsgi's TypedDict-based ones. The unwrapped app only passed because it is untyped; the same mismatch is already ignored one line further down, where the middleware meets httpx's WSGITransport. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Making dag.test()'s in-process supervisor safe to call from several threads concerns any task that calls the Task SDK from its own threads, not only iterated ones, and touches the execution API, models.Variable and models.Connection, so it is reviewed on its own in apache#74074. Until that lands, the two tests that iterate sync items concurrently under dag_maker are expected to fail; they are marked strict xfail so they report the fix once it is in main.
| active_operators_lock=self._active_sub_operators_lock, | ||
| ) | ||
| try: | ||
| with indexed_task_runner: |
There was a problem hiding this comment.
Only run() goes to the pool for a sync operator. IndexedTaskRunner.__exit__ stays on the loop thread, and so do the item's on_success_callback / on_skipped_callback.
So if a notifier reads a connection or variable while a sibling item is inside an async SDK call like aset_state (comms.py:283-290), it gets DeadlockImminentError. _run_task_state_change_callbacks only catches Exception, so that gets out. The item did its work, gets checkpointed UP_FOR_RETRY anyway, and the task fails with no retry. The error message blames an async sub-task.
I know we went over DeadlockImminentError in the earlier thread, but that was user code calling sync APIs from aexecute. Here the user wrote an ordinary sync operator and we're the ones who put it on a thread.
on_kill has the same problem. The SIGTERM handler and _run_tasks call it on the loop thread, and its except Exception means one failure skips the rest of the operators.
Could the sync item's enter/exit run inside the worker thread as well, and on_kill catch BaseException per operator?
| memo[id(self._failed_runners)] = [] | ||
| return super().__deepcopy__(memo) | ||
|
|
||
| def on_kill(self) -> None: |
There was a problem hiding this comment.
On SIGTERM the runner calls this once and the in-flight items are killed, but nothing stops the iteration from pulling more. Each killed item comes back as a failure, _fill_pending (executor.py:229-237) submits the next item into the freed slot, and new items, with their remote jobs, keep starting until the supervisor escalates to SIGKILL. Those never get an on_kill. A stop flag set here, checked by _run_tasks before it submits anything else, would make the kill stick.
| indexed_context: Context = { | ||
| **clone_context(context), | ||
| "ti": self.task_instance, | ||
| "task_instance": self.task_instance, |
There was a problem hiding this comment.
ti, task_instance, task_state_store and outlet_events are swapped, but task isn't, so inside execute and in the item callbacks context["task"] is the IterableOperator. Under .expand() it is the item's own unmapped operator (context_update_for_unmapped sets it, and the rendering in _create_task already gets that). Adding "task": self.task_instance.task here would match.
| argument = await argument.aresolve(context) | ||
| if isinstance(argument, Mapping): | ||
| return cls(list(argument.items())) | ||
| if isinstance(argument, (str, bytes)) or not isinstance(argument, Iterable): |
There was a problem hiding this comment.
This turns a string or other scalar from an upstream XCom into a one-item input. .expand() never sees those, because the upstream's _push_xcom_if_needed raises UnmappableXComTypePushed when it has a mapped dependant. An IterableOperator isn't a MappedOperator, though, so iter_mapped_dependants doesn't find it and that check never fires. An upstream that returns a JSON string makes .iterate() succeed over one wrong item. Raising here when a resolved XComArg isn't is_mappable_value would match .expand().
| """ | ||
| for asset_or_alias, accessor in source.items(): | ||
| target_accessor = target[asset_or_alias] | ||
| target_accessor.extra.update(accessor.extra) |
There was a problem hiding this comment.
Items that emit to the same asset end up as one event: extra.update keeps whatever the last item to finish wrote. So three items that each set extra["file"] produce one event carrying the last file, where .expand() produces three, and a consumer reading triggering_asset_events sees all three. The task instance can only send one event per asset, so this probably can't change, but the comparison table should say so. Users porting a per-file Metadata pattern from .expand() will hit it.
| return False | ||
| if not self._failed_runners[0].task_instance.is_eligible_to_retry: | ||
| return False | ||
| if (policy := self.retry_policy) is not None: |
There was a problem hiding this comment.
With K failed items and a retry_policy, the policy is evaluated K times in _failure_for_the_runner, once more here and once in the runner's _evaluate_retry_policy. For a policy that calls a model, like common.ai's LLMRetryPolicy, that's K+2 calls per failed attempt, and since the answers can differ between calls, the callbacks can announce a retry the runner then doesn't take, which is what the callback change set out to prevent. Could _failure_for_the_runner keep the decision for the exception it hands over, and this reuse that instead of evaluating again?
| that return large results. Mapping deferrable operators | ||
| amplifies the problem further. IT sidesteps triggerers entirely — iterations | ||
| execute on workers, which scale more effectively and support custom XCom | ||
| backends. |
There was a problem hiding this comment.
This presents iteration as a way to keep large results out of the metadata database through a custom XCom backend. But each item's full result is also written to its checkpoint (indexed_task_state.result in _run_task), which goes to the task_state_store table unless a state_store_backend is configured, and stays there for the 30-day retention. Worth saying here and pointing at state_store_backend, so someone iterating over large payloads doesn't just move them from the XCom table to the state store table.
| return None, list(CALLBACKS) | ||
|
|
||
| def test_a_siblings_fail_exception_turns_every_failure_into_a_final_one(self): | ||
| """Kaxil's case: without the wait the ValueError item announced a retry that never came.""" |
There was a problem hiding this comment.
Small one: this docstring and the one at line 1791 refer to me instead of describing the behaviour. The test name and docstring should stand on their own, for example "a failed item's retry callback waits until no sibling rules out the retry".
Was generative AI tooling used to co-author this PR?
Claude Code (Fable 5.1).
Description
This PR is the initial implementation of Iterable Tasks (IT), as discussed in the devlist and building upon the foundations of AIP-104. (Originally prototyped as "Dynamic Task Iteration"; renamed to Iterable Tasks following review feedback to avoid confusion with Dynamic Task Mapping.)
For further context on the use cases and performance benefits of IT, see this Medium Article and the new
dynamic-task-mapping-vs-iteration.rstdoc added in this PR, which compares IT with Dynamic Task Mapping (DTM) and Dynamic Task Batching in depth.The XCom Database Constraint Challenge
While porting our internal "monkey-patched" version of IT (used since Airflow 2.x) to the core, I've identified a significant technical hurdle regarding XCom handling.
Around Airflow 2.10/2.11, a change was introduced to the database constraints for the XCom table. Specifically:
map_index >= 0) unless a corresponding mapped TaskInstance exists in thetask_instancetable.The drawback is that XComs wouldn't automatically be removed from the database when a TaskInstance is deleted, which is the purpose of that constraint. So appending the index to the XCom key would be a good enough solution for IT, but not for DTM.
Current implementation in this PR
XComIterable(airflow.sdk.bases.xcom) appends the sub-task index directly to the XCom key (return_value_<index>) to bypass the constraint, and exposes the results as a lazySequence(__len__/__getitem__/__iter__) so a downstream task can consume them the same way it would consume an.expand()result.XComIterable.flatten()returns aFlattenedXComIterablethat lazily expands nested iterables (e.g. a list of pages into a single stream of items) without ever materializing the flattened stream in memory —__len__/__getitem__/__iter__all speak consistently in flattened items.IterableOperatortracks per-sub-task progress in the AIP-103 Task State Store rather than XCom, and participates in Airflow's standard retry mechanism: if the IterableOperator's task instance is retried (or manually cleared before it finishes), already-succeeded sub-tasks are skipped and only pending/failed ones re-run. XCom is only (re)written per index once a sub-task succeeds; once every index has succeeded, checkpoints are dropped so a subsequent manual clear reruns all indices from scratch rather than replaying stale results.on_killpropagation are handled per sub-task: a checkpointed sub-task replays its recorded outlet events on the following attempt instead of losing them, and killing the IterableOperator's task instance propagates to any sub-tasks still in flight.I believe the cleanest long-term path is still to add a dedicated route in the Execution API that retrieves multiple XComs for a single TaskInstance by a list of keys in one round trip, so
XComIterable.__getitem__/slicing don't need one request per element. I have a PR open to address this, intentionally split out of this PR.This was also discussed in the devcall, see 2026-06-04 Dev Call Minutes.
AIP-104 itself was discussed again in the latest devcall, where the concerns raised there have also been addressed: 2026-09-10 Dev call Minutes.
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 Iterable Tasks (IT) to retrieve the details of each Pokémon. A single task instance processes all Pokémon URLs.
Comparison
get_pokemon.expand(url=urls)get_pokemon.iterate(url=urls)This demonstrates how Task Iteration can significantly reduce TaskInstance creation overhead. Task Spreading (running one iteration over exactly
NTaskInstances with.batch(size=N).iterate(), to be renamed.spread()) is split out into #73688.Notable design points addressed since the initial draft
.iterate()'s dict-argument semantics now match.expand(): passing adictvalue forwards(key, value)pairs to each sub-task instead of bare keys.IterableOperator.task_typeand.operator_nameboth forward to the wrapped operator (including@task-decorated callables with acustom_operator_name), so sub-tasks report the correct type in the UI/API instead of always showingMappedOperator/IterableOperator.XComIterable.flatten()moved out to Add XComIterable.flatten() to read an iterated task's pages as one sequence #73807, stacked on this PR, so this PR stays about running a task over its input.on_kill()propagates to in-flight sub-tasks, and outlet/asset events recorded by a sub-task that already succeeded are replayed from its checkpoint on a later retry instead of being lost.TriggerDagRunOperator, andShortCircuitOperator-style downstream skipping are explicitly rejected insideIterableOperatorwith actionable errors rather than being silently mishandled — see the class docstring for the full list of current limitations.multiple_outputsis ignored for iterated tasks, explicitly at theIterableOperatorlevel. A@taskwith aMappingreturn annotation infersmultiple_outputs=True, but the value the runner pushes for an iterated task is theXComIterableaggregate rather than a dict, so honouring the flag made the runner reject the result after every sub-task had already succeeded. Each sub-task's return value is pushed whole asreturn_value_<index>; keys are not fanned out into separate XComs the way.expand()does. Documented on the class and in the Task SDK docs, and pinned by a runner-level regression test for a dict-returning task under.iterate().Per-iteration keys: XComs and task state
Every iteration of an iterated task runs under the same task instance (same dag id, task id, run id and map index). Anything an iteration writes into a per-task-instance store therefore competes with its siblings for the same key, and with the async executor the winner is whichever iteration finishes last. Two stores are affected, and both now apply the same rule: a key written from inside an iteration carries that iteration's index.
IndexedTaskInstance.xcom_push/axcom_pushsuffix the key with_<index>. That is what makesreturn_value_<index>andXComIterablework, and it applies to any key an operator pushes fromexecute, including the keys of amultiple_outputsdict. Pulls are not suffixed:ti.xcom_pull(task_ids="upstream")reaches the upstream's XCom untouched.context["task_state_store"]). This was a gap: an iteration that stored a watermark or a cursor withtask_state_store.set("last_offset", ...)shared that key with every sibling.IndexedTaskStateStoreAccessorcloses it:IndexedTaskInstance.task_state_storeis the parent's accessor seen through the index, suffixing keys onget/set/deleteand their async twins, and the sub-task's context carries the same object, so an operator does not need to know it is being iterated.clear()is refused inside an iteration, since it would wipe the siblings' state and the operator's own checkpoints; an iteration deletes its own keys instead.IterableOperatorrecords per-index progress in the parent's store under_iterable_<index>and_iterable_completed, written through the parent's accessor, so they are never double-suffixed and never collide with user keys.IndexedTaskRunner(formerlyTaskExecutor, renamed because it read like one of Airflow's executors) builds the context an iteration runs against: a copy of the parent's context with the iteration's own task instance, its indexed state store view and its own outlet events. The operator binds it from thewithstatement, runs the operator inside that block, and records the outcome (checkpoint, XCom push, outlet-event merge) after it, soon_killand the failure callbacks apply to the operator's execution only.{pr_number}.significant.rstor{issue_number}.significant.rst, in airflow-core/newsfragments.