dabla commented on code in PR #62922:
URL: https://github.com/apache/airflow/pull/62922#discussion_r4251950477
##########
task-sdk/src/airflow/sdk/execution_time/task_runner.py:
##########
@@ -945,6 +972,581 @@ def mark_success_url(self) -> str:
return self.log_url
+@dataclass
+class IndexedTaskState:
+ status: TaskInstanceState
+ result: Any | None = None
+ # Outlet asset events the sub-task recorded on a previous successful
attempt. Outlet events
+ # only reach the server on the parent's success payload
(_handle_current_task_success), so an
+ # attempt that fails never registers what its succeeded sub-tasks emitted.
A sub-task skipped
+ # on retry (because it already succeeded) never re-executes, so it never
re-emits into the
+ # fresh OutletEventAccessors created for the new attempt; persisting a
snapshot here lets
+ # IterableOperator._run_task replay it, which is the only way those events
survive, and it
+ # cannot double-emit because the failed attempt sent nothing.
+ outlet_events: list[dict[str, Any]] | None = None
+ # Digest of the input the sub-task ran with. An index only means the same
work while its input
+ # is the same, so a checkpoint is honoured on a later attempt only when
this still matches:
+ # after the upstream was cleared and produced other values, the sub-task
runs again.
+ fingerprint: str | None = None
+ # The attempt that wrote the checkpoint. After a manual clear only the
checkpoints written since
+ # are resumed from (see Checkpoints), which this tells apart from those
left by the run before.
+ try_number: int = 0
+ # The other XComs the sub-task pushed, by their key before the index
suffix. The runner deletes
+ # every XCom before an attempt, so a sub-task skipped on retry has them
pushed again from here,
+ # next to its result, as a mapped task instance that succeeded keeps its
own.
+ xcoms: dict[str, Any] | None = None
+
+ def record_outlet_events(self, accessors: OutletEventAccessors) -> None:
+ """
+ Keep a JSON-safe snapshot of the outlet asset events the indexed task
recorded.
+
+ Persisted with the checkpoint so a later attempt can
:meth:`replay_outlet_events` when
+ the indexed task is skipped because it already succeeded.
+ """
+ events: list[dict[str, Any]] = []
+ for _asset_or_alias, accessor in accessors.items():
+ if isinstance(accessor.key, AssetUniqueKey):
+ events.append(
+ {
+ "kind": "asset",
+ "name": accessor.key.name,
+ "uri": accessor.key.uri,
+ "extra": accessor.extra,
+ "partition_keys": sorted(accessor.partition_keys),
+ }
+ )
+ for alias_event in accessor.asset_alias_events:
+ events.append(
+ {
+ "kind": "asset_alias",
+ "source_alias_name": alias_event.source_alias_name,
+ "dest_asset_key": {
+ "name": alias_event.dest_asset_key.name,
+ "uri": alias_event.dest_asset_key.uri,
+ },
+ "dest_asset_extra": alias_event.dest_asset_extra,
+ "extra": alias_event.extra,
+ }
+ )
+ if events:
+ self.outlet_events = events
+
+ def replay_outlet_events(self, target: OutletEventAccessorsProtocol) ->
None:
+ """
+ Re-emit into ``target`` the events the indexed task recorded on a
previous attempt.
+
+ An indexed task skipped on retry (because it already succeeded) never
re-executes, so it
+ never re-emits into the fresh ``OutletEventAccessors`` created for the
new attempt. The
+ failed attempt sent nothing to the server either (outlet events travel
only on the success
+ payload), so replaying cannot emit an event twice; without it the
events would be lost.
+ They land in the parent's accessors as
:meth:`IndexedTaskRunner.merge_outlet_events_into`
+ lands the live ones: one event per asset per task instance.
+ """
+ for event in self.outlet_events or ():
+ if event["kind"] == "asset":
+ accessor = target[Asset(name=event["name"], uri=event["uri"])]
+ accessor.extra.update(event["extra"])
+ if event["partition_keys"]:
+ accessor.add_partitions(event["partition_keys"])
+ else:
+
target[AssetAlias(name=event["source_alias_name"])].asset_alias_events.append(
+ AssetAliasEvent(
+ source_alias_name=event["source_alias_name"],
+
dest_asset_key=AssetUniqueKey(**event["dest_asset_key"]),
+ dest_asset_extra=event["dest_asset_extra"],
+ extra=event["extra"],
+ )
+ )
+
+ @staticmethod
+ def build_key(index: int) -> str:
+ # The task state store is already scoped to the parent task instance
(dag, run, task and
+ # map index), so the key carries no identity, only a namespace that
keeps the operator's own
+ # entries apart from anything user code stores from inside a sub-task.
+ return f"_iterable_{index}"
+
+ def serialize(self) -> dict[str, Any]:
+ # The checkpoint travels to the supervisor as a JsonValue, which
rejects anything that is not
+ # plain JSON (tuples, datetimes, models, ...). Serde turns those into
JSON-compatible
+ # structures and restores them on read, exactly as XCom does with the
same result.
+ data: dict[str, Any] = {"status": self.status.value}
+ if self.result is not None:
+ data["result"] = serde_serialize(self.result)
+ if self.outlet_events:
+ data["outlet_events"] = self.outlet_events
+ if self.fingerprint:
+ data["fingerprint"] = self.fingerprint
+ if self.try_number:
+ data["try_number"] = self.try_number
+ if self.xcoms:
+ data["xcoms"] = {key: serde_serialize(value) for key, value in
self.xcoms.items()}
+ return data
+
+ @classmethod
+ def deserialize(cls, raw: Any) -> IndexedTaskState | None:
+ if not isinstance(raw, Mapping):
+ return None
+ return cls(
+ status=TaskInstanceState(raw["status"]),
+ result=serde_deserialize(raw.get("result")),
+ outlet_events=raw.get("outlet_events"),
+ fingerprint=raw.get("fingerprint"),
+ try_number=raw.get("try_number", 0),
+ xcoms={key: serde_deserialize(value) for key, value in
raw["xcoms"].items()}
+ if raw.get("xcoms")
+ else None,
+ )
+
+
+class IndexedTaskInstance(RuntimeTaskInstance):
+ """
+ Indexed task instance to run a mapped operator.
+
+ It shares the parent task instance's identity, so what an iteration pushes
or stores lands in
+ the parent's scope, suffixed with the index so that iterations never
overwrite each other:
+ XComs through :meth:`xcom_push`, task state through
:attr:`task_state_store`. Reading back
+ follows the same rule: :meth:`xcom_pull` of the iteration's own XComs adds
the index, a pull
+ from another task does not, as the store's accessor adds it to every key
of its own. The
+ operator's own checkpoints go to the parent's store unsuffixed, under their
+ ``_iterable_<index>`` keys.
+ """
+
+ index: int
+ parent_task_state_store: TaskStateStoreAccessor
+ input_fingerprint: str | None = None
+ # The XComs the sub-task pushed other than its return value, by their key
before the index
+ # suffix: kept in memory while it runs and written once, with its SUCCESS
checkpoint.
+ pushed_xcoms: dict[str, Any] = Field(default_factory=dict)
+
+ @classmethod
+ def create_indexed_task(
+ cls, *, context: Context, index: int, operator: BaseOperator,
input_fingerprint: str | None = None
+ ) -> IndexedTaskInstance:
+ """
+ Create the runtime instance for one index of an iterated task from the
parent's context.
+
+ The instance shares the parent task instance's identity (id, run, map
index, try number and
+ retry budget), so XComs and task state land in the parent's scope, and
carries the unmapped
+ operator for that index. The budget is the parent's ``max_tries``
rather than the operator's
+ ``retries``: a manual clear raises it, and it is the parent Airflow
retries. The parent's
+ state store comes from the context, the same accessor the operator's
+ checkpoints use. ``model_construct`` skips Pydantic validation on
purpose: one instance is built per
+ indexed task, and the parent was validated already, so only the index
needs checking here.
+ """
+ if index < 0:
+ raise ValueError(f"IndexedTaskInstance requires index >= 0, got
{index}")
+ parent = context["ti"]
+ return cls.model_construct(
+ id=parent.id,
+ parent_task_state_store=context["task_state_store"],
+ # The parent's: an iteration's XComs and state belong to the task
instance that runs it.
+ task_id=parent.task_id,
+ dag_id=operator.dag_id,
+ run_id=parent.run_id,
+ map_index=parent.map_index,
+ region_id=getattr(parent, "region_id", None),
+ region_index=getattr(parent, "region_index", None),
+ index=index,
+ input_fingerprint=input_fingerprint,
+ max_tries=parent.max_tries,
+ start_date=parent.start_date,
+ state=TaskInstanceState.SCHEDULED.value,
+ is_mapped=True,
+ task=operator,
+ try_number=parent.try_number,
+ # The dag run, logical date and the rest the server sent for the
task instance that
+ # runs this iteration: its template context, get_previous_ti() and
the like read them.
+ _ti_context_from_server=getattr(parent, "_ti_context_from_server",
None),
+ )
+
+ def xcom_push(
+ self,
+ key: str,
+ value: Any,
+ ):
+ super().xcom_push(key=f"{key}_{self.index}", value=value)
+ self._record_push(key, value)
+
+ async def axcom_push(
+ self,
+ key: str,
+ value: Any,
+ ):
+ await super().axcom_push(key=f"{key}_{self.index}", value=value)
+ self._record_push(key, value)
+
+ def _record_push(self, key: str, value: Any) -> None:
+ # The return value has its own slot on the checkpoint and is published
by the operator.
+ if key != BaseXCom.XCOM_RETURN_KEY:
+ self.pushed_xcoms[key] = value
+
+ def _own_key(self, task_ids: str | Iterable[str] | None, dag_id: str |
None, key: str) -> str:
+ """
+ Suffix ``key`` with the index for a pull of this iteration's own XComs.
+
+ What :meth:`xcom_push` wrote under ``<key>_<index>`` is read back
under the same name when
+ the pull names no task or this task instance's own (in its own DAG); a
pull from another
+ task, or from several, keeps its key, since those XComs carry no index.
+ """
+ own_task = task_ids is None or task_ids == self.task_id
+ own_dag = dag_id is None or dag_id == self.dag_id
+ return f"{key}_{self.index}" if own_task and own_dag else key
+
+ def xcom_pull(
+ self,
+ task_ids: str | Iterable[str] | None = None,
+ dag_id: str | None = None,
+ key: str = BaseXCom.XCOM_RETURN_KEY,
+ include_prior_dates: bool = False,
+ *,
+ map_indexes: int | Iterable[int] | None | ArgNotSet = NOTSET,
+ default: Any = None,
+ run_id: str | None = None,
+ ) -> Any:
+ return super().xcom_pull(
+ task_ids=task_ids,
+ dag_id=dag_id,
+ key=self._own_key(task_ids, dag_id, key),
+ include_prior_dates=include_prior_dates,
+ map_indexes=map_indexes,
+ default=default,
+ run_id=run_id,
+ )
+
+ async def axcom_pull(
+ self,
+ task_ids: str | Iterable[str] | None = None,
+ dag_id: str | None = None,
+ key: str = BaseXCom.XCOM_RETURN_KEY,
+ include_prior_dates: bool = False,
+ *,
+ map_indexes: int | Iterable[int] | None | ArgNotSet = NOTSET,
+ default: Any = None,
+ run_id: str | None = None,
+ ) -> Any:
+ return await super().axcom_pull(
+ task_ids=task_ids,
+ dag_id=dag_id,
+ key=self._own_key(task_ids, dag_id, key),
+ include_prior_dates=include_prior_dates,
+ map_indexes=map_indexes,
+ default=default,
+ run_id=run_id,
+ )
+
+ @cached_property
+ def task_state_store(self) -> TaskStateStoreAccessor: # type:
ignore[override]
+ """The parent's store seen from this iteration: keys are suffixed with
the index."""
+ return IndexedTaskStateStoreAccessor(self.parent_task_state_store,
self.index)
+
+ def context_for(self, context: Context, *, outlet_events:
OutletEventAccessors | None = None) -> Context:
+ """
+ Return the parent's context as this indexed task sees it.
+
+ A clone of ``context`` with this task instance under ``ti`` and
``task_instance``, its
+ unmapped operator under ``task`` and its indexed view of the store
under
+ ``task_state_store``: the keys ``context_update_for_unmapped`` sets
for a mapped task
+ instance, and the store next to them, so that a template and
``execute`` read the same
+ store. The one place these keys are listed:
:meth:`IndexedTaskRunner.indexed_context`
+ builds the context the task runs in from it, and
``IterableOperator._create_task`` the
+ one its templates are rendered against. ``outlet_events`` is swapped
when given, for the
+ run; before it, nothing is emitted and the parent's accessor stays.
+ """
+ indexed: Context = {
+ **clone_context(context),
+ "ti": self,
+ "task_instance": self,
+ "task": self.task,
+ "task_state_store": self.task_state_store,
+ }
+ if outlet_events is not None:
+ indexed["outlet_events"] = outlet_events
+ return indexed
+
+ async def aget_state(self) -> IndexedTaskState | None:
+ return IndexedTaskState.deserialize(await
self.parent_task_state_store.aget(self.state_key))
+
+ async def aset_state(self, state: IndexedTaskState) -> None:
+ await self.parent_task_state_store.aset(self.state_key,
state.serialize())
+
+ @property
+ def is_async(self) -> bool:
+ return self.task.is_async
+
+ @property
+ def is_eligible_to_retry(self) -> bool:
+ """
+ Whether Airflow runs the parent again after this attempt fails.
+
+ The same rule the API server applies to the parent
(``_is_eligible_to_retry``), so the
+ callbacks of an iteration agree with what happens to the task instance.
+ """
+ return self.max_tries != 0 and self.try_number <= self.max_tries
+
+ @property
+ def state_key(self) -> str:
+ return IndexedTaskState.build_key(self.index)
+
+ @property
+ def do_xcom_push(self) -> bool:
+ return self.task.do_xcom_push
+
+
+class SubOperatorRegister(Protocol):
+ """Where an indexed task's operator is noted while its code runs, so that
a kill reaches it."""
+
+ def register(self, operator: BaseOperator) -> None: ...
+
+ def unregister(self, operator: BaseOperator) -> None: ...
+
+
+class IndexedTaskRunner(LoggingMixin):
+ """
+ Run one indexed task of an iterated task: its operator, against its own
view of the context.
+
+ Named apart from Airflow's executors, which schedule task instances, and
from the task runner
+ process, which runs the parent: this runs one index inside that process,
sync or async.
+ """
+
+ def __init__(
+ self,
+ task_instance: IndexedTaskInstance,
+ register: SubOperatorRegister | None = None,
+ outlet_events: OutletEventAccessors | None = None,
+ ):
+ """
+ Run an operator or trigger for one sub-task instance.
+
+ :param outlet_events: The accessor the sub-task's asset events are
collected in, its own
+ so they can be checkpointed and merged apart from its siblings'.
Created here when not
+ given; the caller reads it back through :attr:`outlet_events`
after the run.
+ :param register: Optional register the operator is entered in while
its code runs (see
+ :meth:`in_flight`), so that IterableOperator.on_kill() can reach
whichever sub-tasks
+ are in flight; the iterated task's ``IterationState``.
+ """
+ super().__init__()
+ self.task_instance = task_instance
+ self.outlet_events = outlet_events if outlet_events is not None else
OutletEventAccessors()
+ self._result: Any | None = None
+ self._start_time: float | None = None
+ self._context: Context | None = None
+ self._register = register
+ #: The exception this indexed task failed with, noted by __exit__ and
reported by
+ #: :meth:`report_failure` once the whole task's fate is known.
+ self.failure: BaseException | None = None
+ #: Whether the ``AirflowTaskTimeout`` this indexed task ended with was
raised by its operator
+ #: off the main thread, where the parent's limit never strikes; see
:meth:`in_flight`.
+ self.timed_out_on_its_own = False
+ self._cancelled = False
+
+ def merge_outlet_events_into(self, target: OutletEventAccessorsProtocol)
-> None:
+ """
+ Fold the outlet asset events this indexed task recorded into the
parent's ``target``.
+
+ Called right after the indexed task succeeds, on the
IterableOperator's shared
+ ``context["outlet_events"]``. A task instance sends one event per
asset, so indexed tasks
+ that emit to the same asset end up in that one event: ``extra`` keeps
what the last one to
+ finish wrote, while partition keys and alias events accumulate.
``.expand()`` sends one
+ event per mapped task instance instead; the docs page says so in its
comparison table.
+ """
+ for asset_or_alias, accessor in self.outlet_events.items():
+ target_accessor = target[asset_or_alias]
+ target_accessor.extra.update(accessor.extra)
+
target_accessor.asset_alias_events.extend(accessor.asset_alias_events)
+ target_accessor.partition_keys.update(accessor.partition_keys)
+
+ def cancel(self) -> None:
+ """
+ Note that the coroutine waiting for this sync indexed task was
cancelled.
+
+ Its thread may go on, but the task gets no checkpoint from here on,
and no report either:
+ the next attempt runs it again and reports it then. Best effort: a
thread already past the
+ check in its exit still notes a failure.
+ """
+ self._cancelled = True
+
+ @property
+ def cancelled(self) -> bool:
+ """Whether :meth:`cancel` was called: the indexed task's exit then
notes nothing."""
+ return self._cancelled
+
+ @property
+ def dag_id(self) -> str:
+ return self.task_instance.dag_id
+
+ @property
+ def task_id(self) -> str:
+ return self.task_instance.task_id
+
+ @property
+ def task_index(self) -> int:
+ return self.task_instance.index
+
+ @property
+ def operator(self) -> BaseOperator:
+ return self.task_instance.task
+
+ @property
+ def is_async(self) -> bool:
+ return self.task_instance.is_async
+
+ @contextmanager
+ def indexed_context(self, context: Context) -> Generator[Context, None,
None]:
+ """
+ Enter the parent's context as this indexed task sees it.
+
+ Yields the task instance's :meth:`IndexedTaskInstance.context_for`
view of the parent's
+ context with this runner's own outlet events, remembered on the runner
and made the
+ current context for the duration of the block, so user code reads the
indexed task's
+ unmapped operator under ``context["task"]``, not the IterableOperator.
The parent's
+ context is left untouched: ``context_update_for_unmapped`` sets
``ti.task`` on whatever
+ ``ti`` it finds, which must be this task's, not the parent's.
+ """
+ indexed_context = self.task_instance.context_for(context,
outlet_events=self.outlet_events)
+ self._context = indexed_context
+ with set_indexed_context(indexed_context):
+ yield indexed_context
+
+ @contextmanager
+ def in_flight(self) -> Generator[None, None, None]:
+ """
+ Register the operator as running for exactly as long as its code runs.
+
+ Entered by :meth:`run` and :meth:`arun`, so in the worker thread or
coroutine that executes
+ the operator: a sync operator stays registered while its thread is
still inside ``execute``,
+ even after the coroutine waiting for it was cancelled, and
``IterableOperator.on_kill`` can
+ reach it. The operator the parent's execution timeout strikes stays
registered as well:
+ the timeout lands on the main thread, where the loop runs async
operators, and its
+ ``on_kill`` must not run there, where a synchronous SDK call raises, so
+ ``IterableOperator._run_tasks`` kills it off the loop thread with the
others once the
+ timeout has unwound. ``TimeoutPosix`` raises on the main thread only,
so an
+ ``AirflowTaskTimeout`` on a worker thread was raised by the operator
itself (a hook that
+ gave up waiting): that is this indexed task's own failure, noted as
+ :attr:`timed_out_on_its_own`, and the operator is unregistered like
any other.
+ """
+ if self._register is not None:
+ self._register.register(self.operator)
+ struck_by_the_parent = False
+ try:
+ yield
+ except AirflowTaskTimeout:
+ self.timed_out_on_its_own = threading.current_thread() is not
threading.main_thread()
Review Comment:
Agreed, documented as a limitation for now (060fe8c84b). The iterable tasks
page has a warning in its async section, next to the one on synchronous calls:
an async task raising `AirflowTaskTimeout` itself is taken for the task's own
`execution_timeout`, so the iteration stops, the running iterations are killed
and the task is retried as on its own timeout; a sync task is not concerned,
and for a time limit of the operator's own it should raise another exception
such as `TimeoutError`. The `in_flight` docstring names it too. Telling the two
apart with a flag set by the timeout handler would be the real fix; I would
rather keep that as a follow-up than change the timeout path in this PR.
Drafted-by: Claude Opus 5.5; reviewed by @dabla before posting
--
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.
To unsubscribe, e-mail: [email protected]
For queries about this service, please contact Infrastructure at:
[email protected]