Skip to content

Commit 331aaa6

Browse files
committed
Keep the state of one iteration run in IterationState
IterableOperator carried seven attributes for what one run remembers while it is going: the sub-operators in flight with their lock and the ones already killed, the stop flag, the runners of the failed items, the resolved input and the retry policy's decision for the exception handed to the runner, plus a __deepcopy__ override to give every copy fresh ones. They move into IterationState, in the same module. A copy of the state is a fresh one, so the override goes, and the operator starts every run with a fresh state too. The state exposes what the operator means rather than its containers: register/unregister and take_in_flight for the kill, request_stop/stop_requested for the executor's stop, note_failed and the failed_runners tuple, keep_decision/decision_for, and length for the resolved input; the operator makes no reach-through call into it. IndexedTaskRunner registers through a SubOperatorRegister protocol instead of a dict and a lock. Tests cover the state on its own: registration until unregistered, keying by identity for operators that compare equal, take_in_flight handing each operator out once, the stop flag, the order of failed runners, a kept decision answering for that exception object only, length unknown until resolved, and a deep copy being fresh while the original keeps its state.
1 parent 96b597f commit 331aaa6

4 files changed

Lines changed: 276 additions & 131 deletions

File tree

‎task-sdk/src/airflow/sdk/definitions/iterableoperator.py‎

Lines changed: 109 additions & 61 deletions
Original file line numberDiff line numberDiff line change
@@ -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+
303389
class 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:

‎task-sdk/src/airflow/sdk/execution_time/task_runner.py‎

Lines changed: 18 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,6 @@
2525
import logging
2626
import os
2727
import sys
28-
import threading
2928
import time
3029
from asyncio import CancelledError, wait_for
3130
from collections.abc import Callable, Iterable, Iterator, Mapping
@@ -35,7 +34,7 @@
3534
from functools import cached_property
3635
from itertools import product
3736
from pathlib import Path
38-
from typing import TYPE_CHECKING, Annotated, Any, Literal, NoReturn, cast
37+
from typing import TYPE_CHECKING, Annotated, Any, Literal, NoReturn, Protocol, cast
3938
from urllib.parse import quote
4039

4140
import attrs
@@ -1093,6 +1092,14 @@ def do_xcom_push(self) -> bool:
10931092
return self.task.do_xcom_push
10941093

10951094

1095+
class SubOperatorRegister(Protocol):
1096+
"""Where an indexed task's operator is noted while its code runs, so that a kill reaches it."""
1097+
1098+
def register(self, operator: BaseOperator) -> None: ...
1099+
1100+
def unregister(self, operator: BaseOperator) -> None: ...
1101+
1102+
10961103
class IndexedTaskRunner(LoggingMixin):
10971104
"""
10981105
Run one indexed task of an iterated task: its operator, against its own view of the context.
@@ -1104,8 +1111,7 @@ class IndexedTaskRunner(LoggingMixin):
11041111
def __init__(
11051112
self,
11061113
task_instance: IndexedTaskInstance,
1107-
active_operators: dict[int, BaseOperator] | None = None,
1108-
active_operators_lock: threading.Lock | None = None,
1114+
register: SubOperatorRegister | None = None,
11091115
outlet_events: OutletEventAccessors | None = None,
11101116
):
11111117
"""
@@ -1114,21 +1120,17 @@ def __init__(
11141120
:param outlet_events: The accessor the sub-task's asset events are collected in, its own
11151121
so they can be checkpointed and merged apart from its siblings'. Created here when not
11161122
given; the caller reads it back through :attr:`outlet_events` after the run.
1117-
:param active_operators: Optional shared register, keyed by ``id(operator)``, that the
1118-
operator is entered in while its code runs (see :meth:`in_flight`), so that
1119-
IterableOperator.on_kill() can reach whichever sub-tasks are in flight. Keyed by
1120-
identity because the sub-operators of one iterated task compare equal.
1121-
:param active_operators_lock: Lock guarding ``active_operators``, required whenever
1122-
``active_operators`` is given since multiple sub-tasks may run concurrently.
1123+
:param register: Optional register the operator is entered in while its code runs (see
1124+
:meth:`in_flight`), so that IterableOperator.on_kill() can reach whichever sub-tasks
1125+
are in flight; the iterated task's ``IterationState``.
11231126
"""
11241127
super().__init__()
11251128
self.task_instance = task_instance
11261129
self.outlet_events = outlet_events if outlet_events is not None else OutletEventAccessors()
11271130
self._result: Any | None = None
11281131
self._start_time: float | None = None
11291132
self._context: Context | None = None
1130-
self._active_operators = active_operators
1131-
self._active_operators_lock = active_operators_lock
1133+
self._register = register
11321134
#: The exception this indexed task failed with, noted by __exit__ and reported by
11331135
#: :meth:`report_failure` once the whole task's fate is known.
11341136
self.failure: BaseException | None = None
@@ -1205,10 +1207,8 @@ def in_flight(self) -> Iterator[None]:
12051207
main thread, as async operators do), it is killed here, since it leaves the register as the
12061208
timeout unwinds and the parent's ``on_kill`` would no longer see it.
12071209
"""
1208-
registered = self._active_operators is not None and self._active_operators_lock is not None
1209-
if registered:
1210-
with self._active_operators_lock: # type: ignore[union-attr]
1211-
self._active_operators[id(self.operator)] = self.operator # type: ignore[index]
1210+
if self._register is not None:
1211+
self._register.register(self.operator)
12121212
try:
12131213
yield
12141214
except AirflowTaskTimeout:
@@ -1218,9 +1218,8 @@ def in_flight(self) -> Iterator[None]:
12181218
self.log.exception("Error calling on_kill() for sub-task operator %s", self.task_id)
12191219
raise
12201220
finally:
1221-
if registered:
1222-
with self._active_operators_lock: # type: ignore[union-attr]
1223-
self._active_operators.pop(id(self.operator), None) # type: ignore[union-attr]
1221+
if self._register is not None:
1222+
self._register.unregister(self.operator)
12241223

12251224
def run(self, context: Context):
12261225
"""Run the operator synchronously against this indexed task's own view of ``context``."""

0 commit comments

Comments
 (0)