@@ -300,6 +300,92 @@ def __exit__(self, exc_type, exc_value, traceback) -> None:
300300 self ._store .set (self .COMPLETION_KEY , {"completed" : True , "try_number" : self ._try_number })
301301
302302
303+ class IterationState :
304+ """
305+ The state of one run of an iterated task, kept apart from the operator's configuration.
306+
307+ It holds what the run needs to remember while it is going: the sub-operators in flight and
308+ those already killed, so that :meth:`IterableOperator.on_kill` reaches each one once; the
309+ stop flag a kill sets, which the executor consults before starting the next item; the
310+ runners of the items that failed, whose callbacks wait for the task's fate; the resolved
311+ input; and the retry policy's decision for the exception handed to the runner.
312+
313+ A deep copy of the operator (``dag.partial_subset``, ``prepare_for_execution``) is another
314+ task with nothing in flight and no kill pending, so copying the state gives a fresh one. The
315+ operator starts every run with a fresh one as well.
316+ """
317+
318+ def __init__ (self ) -> None :
319+ # Keyed by identity: BaseOperator equality compares fields such as task_id, which every
320+ # sub-operator of one iterated task shares, so a set would hold one of them at most.
321+ self ._in_flight : dict [int , BaseOperator ] = {}
322+ self ._lock = threading .Lock ()
323+ # The runner calls on_kill() again after the execution timeout that made _run_tasks call
324+ # it first, and a sub-operator is killed once.
325+ self ._killed : set [int ] = set ()
326+ self ._stop_requested = threading .Event ()
327+ self ._failed_runners : list [IndexedTaskRunner ] = []
328+ self ._decision : tuple [BaseException , RetryDecision ] | None = None
329+ #: The input resolved for this task instance, once ``aresolve`` returned.
330+ self .resolved : Resolved | None = None
331+
332+ def __deepcopy__ (self , memo : dict [int , Any ]) -> IterationState :
333+ return IterationState ()
334+
335+ def register (self , operator : BaseOperator ) -> None :
336+ """Note that ``operator`` is executing, so a kill reaches it."""
337+ with self ._lock :
338+ self ._in_flight [id (operator )] = operator
339+
340+ def unregister (self , operator : BaseOperator ) -> None :
341+ """Note that ``operator`` is done, one way or another."""
342+ with self ._lock :
343+ self ._in_flight .pop (id (operator ), None )
344+
345+ def __contains__ (self , operator : object ) -> bool :
346+ with self ._lock :
347+ return id (operator ) in self ._in_flight
348+
349+ def take_in_flight (self ) -> list [BaseOperator ]:
350+ """Return the sub-operators in flight that were not handed out before, and mark them killed."""
351+ with self ._lock :
352+ operators = [op for key , op in self ._in_flight .items () if key not in self ._killed ]
353+ self ._killed .update (map (id , operators ))
354+ return operators
355+
356+ def request_stop (self ) -> None :
357+ """Ask the iteration to start nothing else; see :meth:`stop_requested`."""
358+ self ._stop_requested .set ()
359+
360+ def stop_requested (self ) -> bool :
361+ """Whether :meth:`request_stop` was called; passed to the executor as its ``stop``."""
362+ return self ._stop_requested .is_set ()
363+
364+ @property
365+ def length (self ) -> int | None :
366+ """How many items the resolved input has, or None while it is still being resolved."""
367+ return self .resolved .length if self .resolved is not None else None
368+
369+ def note_failed (self , runner : IndexedTaskRunner ) -> None :
370+ """Remember a failed item's runner: its callback waits for the task's fate."""
371+ self ._failed_runners .append (runner )
372+
373+ @property
374+ def failed_runners (self ) -> tuple [IndexedTaskRunner , ...]:
375+ """The runners of the items that failed in this run, in the order they failed."""
376+ return tuple (self ._failed_runners )
377+
378+ def keep_decision (self , exception : BaseException , decision : RetryDecision ) -> None :
379+ """Keep the retry policy's decision for the exception handed to the runner."""
380+ self ._decision = (exception , decision )
381+
382+ def decision_for (self , exception : BaseException ) -> RetryDecision | None :
383+ """The decision kept for ``exception``, if it is the one handed to the runner."""
384+ if self ._decision is not None and self ._decision [0 ] is exception :
385+ return self ._decision [1 ]
386+ return None
387+
388+
303389class IterableOperator (BaseOperator ):
304390 """
305391 Operator used for Iterable Tasks (IT) that runs a mapped operator over an iterable input.
@@ -458,7 +544,6 @@ class IterableOperator(BaseOperator):
458544 "expand_input" ,
459545 "partial_kwargs" ,
460546 "_log" ,
461- "_resolved" ,
462547 )
463548
464549 def __init__ (
@@ -560,51 +645,17 @@ def __init__(
560645 for key , value in self .partial_kwargs .items ():
561646 if key in self ._operator .template_fields :
562647 XComArg .apply_upstream_relationship (self , value )
563- # Populated with each sub-task's unmapped operator while it is actively executing, so
564- # on_kill() (see below) can propagate a kill/timeout signal to whichever sub-tasks happen
565- # to be in flight; guarded by a lock since sub-tasks execute concurrently.
566- # Keyed by identity: BaseOperator equality compares fields such as task_id, which every
567- # sub-task of one iterated task shares, so a set would hold one of them at most.
568- self ._active_sub_operators : dict [int , BaseOperator ] = {}
569- self ._active_sub_operators_lock = threading .Lock ()
570- # Identities of the sub-operators on_kill() already reached: the runner calls it again after
571- # the execution timeout that made _run_tasks call it first, and an operator is killed once.
572- self ._killed_sub_operators : set [int ] = set ()
573- # Runners of the sub-tasks that failed in this run: their failure or retry callback waits
574- # for the whole task's fate (see _report_failed_items).
575- self ._failed_runners : list [IndexedTaskRunner ] = []
576- # Per-run state of execute: the input resolved for this task instance.
577- self ._resolved : Resolved | None = None
578- # The retry policy's decision for the exception handed to the runner, kept so the failed
579- # items' callbacks follow it instead of evaluating the policy once more (see _task_will_retry).
580- self ._decision_for_the_runner : tuple [BaseException , RetryDecision ] | None = None
581- # Set by on_kill(): the executor pulls no further item once it is set (see _run_tasks), so
582- # a kill stops the iteration instead of being followed by the next items starting.
583- self ._stop_requested = threading .Event ()
584-
585- def __deepcopy__ (self , memo : dict [int , Any ]) -> IterableOperator :
586- # A copy (deepcopy, dag.partial_subset) is another task with no sub-tasks in flight: it gets
587- # a lock and a set of its own. The lock cannot be copied at all, so both are handed to
588- # BaseOperator.__deepcopy__ through the memo it consults for every attribute.
589- memo [id (self ._active_sub_operators )] = {}
590- memo [id (self ._active_sub_operators_lock )] = threading .Lock ()
591- memo [id (self ._killed_sub_operators )] = set ()
592- memo [id (self ._failed_runners )] = []
593- memo [id (self ._stop_requested )] = threading .Event ()
594- return super ().__deepcopy__ (memo )
595-
648+ # What one run remembers while it is going (see IterationState); fresh for every run and
649+ # for every copy of the operator.
650+ self ._state = IterationState ()
596651 def on_kill (self ) -> None :
597652 # The default BaseOperator.on_kill() is a no-op, which would otherwise leave every
598653 # currently in-flight sub-task unaware that the IterableOperator itself was killed
599654 # (SIGTERM) or hit its execution_timeout: propagate to each active sub-operator instead.
600655 # First stop the iteration from starting anything else: the killed items come back as
601656 # failures and free their slots, which would otherwise be filled with the next items.
602- self ._stop_requested .set ()
603- with self ._active_sub_operators_lock :
604- active_operators = [
605- op for key , op in self ._active_sub_operators .items () if key not in self ._killed_sub_operators
606- ]
607- self ._killed_sub_operators .update (map (id , active_operators ))
657+ self ._state .request_stop ()
658+ active_operators = self ._state .take_in_flight ()
608659 if not active_operators :
609660 return
610661 try :
@@ -691,9 +742,7 @@ def _run_tasks(
691742 total = 0
692743 do_xcom_push = True
693744
694- self ._failed_runners = []
695- self ._decision_for_the_runner = None
696- self ._stop_requested .clear ()
745+ self ._state = IterationState ()
697746 try :
698747 self .log .info ("Running tasks with %d workers" , self .max_workers )
699748
@@ -710,7 +759,7 @@ def _run_tasks(
710759 since = checkpoints .since ,
711760 ),
712761 tasks ,
713- stop = self ._stop_requested . is_set ,
762+ stop = self ._state . stop_requested ,
714763 ):
715764 total += 1
716765 do_xcom_push = task .do_xcom_push
@@ -789,14 +838,14 @@ def _run_tasks(
789838 self .on_kill ()
790839 raise
791840
792- if self ._stop_requested . is_set ():
841+ if self ._state . stop_requested ():
793842 # Killed: nothing started once on_kill() ran, and what was in flight has
794843 # finished one way or another. The task fails without a retry, as the runner
795844 # treats a terminated task; no completion marker is written, so a later clear
796845 # resumes from the checkpoints of the items that did finish. Checked before the
797846 # other outcomes: with every killed item returning normally there would be no
798847 # failure to raise, and a kill while the input resolves is not an empty input.
799- length = self ._resolved .length if self . _resolved is not None else None
848+ length = self ._state .length
800849 raise AirflowTaskTerminated (
801850 f"The iterated task was killed: { total } of { length } items ran, the rest never started."
802851 if length is not None
@@ -825,10 +874,10 @@ def _report_failed_items(self, context: Context, raised: BaseException) -> None:
825874 item no longer announces a retry a sibling's ``AirflowFailException`` then rules out. They
826875 run one after another, on the thread that ran the iteration.
827876 """
828- if not self ._failed_runners :
877+ if not self ._state . failed_runners :
829878 return
830879 task_will_retry = self ._task_will_retry (context , raised )
831- for runner in self ._failed_runners :
880+ for runner in self ._state . failed_runners :
832881 runner .report_failure (task_will_retry = task_will_retry )
833882
834883 def _task_will_retry (self , context : Context , raised : BaseException ) -> bool :
@@ -844,11 +893,11 @@ def _task_will_retry(self, context: Context, raised: BaseException) -> bool:
844893 """
845894 if isinstance (raised , FAIL_WITHOUT_RETRY ) or not isinstance (raised , (Exception , AirflowTaskTimeout )):
846895 return False
847- if not self ._failed_runners [0 ].task_instance .is_eligible_to_retry :
896+ if not self ._state . failed_runners [0 ].task_instance .is_eligible_to_retry :
848897 return False
849898 if (policy := self .retry_policy ) is not None :
850- if (kept := self ._decision_for_the_runner ) is not None and kept [ 0 ] is raised :
851- decision = kept [ 1 ]
899+ if (kept := self ._state . decision_for ( raised )) is not None :
900+ decision = kept
852901 else :
853902 ti = context ["ti" ]
854903 from_server = getattr (ti , "_ti_context_from_server" , None )
@@ -904,7 +953,7 @@ def _failure_for_the_runner(self, context: Context, exceptions: list[Exception])
904953 index = weights .index (max (weights ))
905954 chosen = exceptions [index ]
906955 if (kept := decisions [index ]) is not None :
907- self ._decision_for_the_runner = (chosen , kept )
956+ self ._state . keep_decision (chosen , kept )
908957 if chosen is None :
909958 return group
910959 if len (exceptions ) > 1 :
@@ -982,8 +1031,7 @@ async def _run_task(
9821031 # _serialize_outlet_events/_merge_outlet_events).
9831032 indexed_task_runner = IndexedTaskRunner (
9841033 task_instance = task ,
985- active_operators = self ._active_sub_operators ,
986- active_operators_lock = self ._active_sub_operators_lock ,
1034+ register = self ._state ,
9871035 )
9881036 try :
9891037 if task .is_async :
@@ -1032,7 +1080,7 @@ def run_item():
10321080 # so its exit must report nothing either (see IndexedTaskRunner.cancel).
10331081 indexed_task_runner .cancel ()
10341082 if indexed_task_runner .failure is not None :
1035- self ._failed_runners . append (indexed_task_runner )
1083+ self ._state . note_failed (indexed_task_runner )
10361084 raise
10371085 except AirflowSkipException as e :
10381086 await task .aset_state (
@@ -1045,7 +1093,7 @@ def run_item():
10451093 return task , None , e
10461094 except BaseException as e :
10471095 if indexed_task_runner .failure is not None :
1048- self ._failed_runners . append (indexed_task_runner )
1096+ self ._state . note_failed (indexed_task_runner )
10491097 # Written with the input and the attempt, like the other outcomes, so the next attempt
10501098 # tells a plain retry apart from a clear or a changed input and logs only the latter.
10511099 await task .aset_state (
@@ -1109,8 +1157,8 @@ async def tasks() -> AsyncIterator[IndexedTaskInstance]:
11091157 # Resolved and read by the executor on the running event loop, so the input's XCom
11101158 # reads go through asend and cannot deadlock with the sub-tasks' own SDK calls (see
11111159 # AsyncAwareExecutor.imap_unordered).
1112- self ._resolved = await self .expand_input .aresolve (context )
1113- for index in range (self ._resolved .length ):
1160+ self ._state . resolved = await self .expand_input .aresolve (context )
1161+ for index in range (self ._state . resolved .length ):
11141162 # Rendering may call the supervisor synchronously (an XComArg in a partial kwarg,
11151163 # ``{{ var.value.x }}``, ``{{ conn.x }}``), which raises DeadlockImminentError on the
11161164 # loop thread while a sub-task's asend is in flight. From a worker thread the same
@@ -1119,7 +1167,7 @@ async def tasks() -> AsyncIterator[IndexedTaskInstance]:
11191167 self ._create_task ,
11201168 context = context ,
11211169 index = index ,
1122- mapped_kwargs = await self ._resolved .aget (index ),
1170+ mapped_kwargs = await self ._state . resolved .aget (index ),
11231171 jinja_env = jinja_env ,
11241172 )
11251173
@@ -1129,11 +1177,11 @@ async def tasks() -> AsyncIterator[IndexedTaskInstance]:
11291177 task_id = self .task_id ,
11301178 dag_id = self .dag_id ,
11311179 run_id = context ["run_id" ],
1132- length = self ._resolved .length ,
1180+ length = self ._state .length ,
11331181 map_index = context ["ti" ].map_index ,
11341182 skipped = skipped ,
11351183 )
1136- if do_xcom_push and self ._resolved
1184+ if do_xcom_push and self ._state . resolved
11371185 else None
11381186 )
11391187 if skipped :
0 commit comments