This is an automated email from the ASF dual-hosted git repository.
uranusjr pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/airflow.git
The following commit(s) were added to refs/heads/main by this push:
new 279565022b Consider mapped upstream in TriggerRuleDep (#22583)
279565022b is described below
commit 279565022b94d2dc8a1ae0f7dd6bf4d1dd10dbdc
Author: Tzu-ping Chung <[email protected]>
AuthorDate: Wed Apr 6 14:27:46 2022 +0800
Consider mapped upstream in TriggerRuleDep (#22583)
---
airflow/models/baseoperator.py | 2 +-
airflow/models/taskinstance.py | 30 +++++++++++++----
airflow/models/xcom.py | 4 ++-
airflow/models/xcom_arg.py | 15 ++++-----
airflow/ti_deps/deps/trigger_rule_dep.py | 41 +++++++++++++++++++----
tests/models/test_dagrun.py | 51 +++++++++++++++++++++++++++++
tests/models/test_taskinstance.py | 32 ++++++++++++++++++
tests/ti_deps/deps/test_trigger_rule_dep.py | 8 ++---
8 files changed, 156 insertions(+), 27 deletions(-)
diff --git a/airflow/models/baseoperator.py b/airflow/models/baseoperator.py
index 8f65f9c39a..1e65bb9635 100644
--- a/airflow/models/baseoperator.py
+++ b/airflow/models/baseoperator.py
@@ -837,7 +837,7 @@ class BaseOperator(AbstractOperator,
metaclass=BaseOperatorMeta):
f"'{dag.dag_id if dag else ''}.{task_id}'; received
'{trigger_rule}'."
)
- self.trigger_rule = trigger_rule
+ self.trigger_rule = TriggerRule(trigger_rule)
self.depends_on_past: bool = depends_on_past
self.ignore_first_depends_on_past = ignore_first_depends_on_past
self.wait_for_downstream = wait_for_downstream
diff --git a/airflow/models/taskinstance.py b/airflow/models/taskinstance.py
index 60ce957398..b9a1ebc148 100644
--- a/airflow/models/taskinstance.py
+++ b/airflow/models/taskinstance.py
@@ -823,17 +823,25 @@ class TaskInstance(Base, LoggingMixin):
self.operator = task.task_type
@provide_session
- def clear_xcom_data(self, session=NEW_SESSION):
- """
- Clears all XCom data from the database for the task instance
+ def clear_xcom_data(self, session: Session = NEW_SESSION):
+ """Clear all XCom data from the database for the task instance.
+
+ If the task is unmapped, all XComs matching this task ID in the same
DAG
+ run are removed. If the task is mapped, only the one with matching map
+ index is removed.
:param session: SQLAlchemy ORM Session
"""
self.log.debug("Clearing XCom data")
+ if self.map_index < 0:
+ map_index: Optional[int] = None
+ else:
+ map_index = self.map_index
XCom.clear(
dag_id=self.dag_id,
task_id=self.task_id,
run_id=self.run_id,
+ map_index=map_index,
session=session,
)
@@ -2279,6 +2287,7 @@ class TaskInstance(Base, LoggingMixin):
session: Session = NEW_SESSION,
*,
map_indexes: Optional[Union[int, Iterable[int]]] = None,
+ default: Any = None,
) -> Any:
"""Pull XComs that optionally meet certain criteria.
@@ -2303,7 +2312,8 @@ class TaskInstance(Base, LoggingMixin):
the specified task is mapped. If not, value from the one single task
instance is returned. If the task to pull is mapped, an iterator (not a
list) yielding XComs from mapped task instances is returned. In either
- case, *None* is returned if no matching XComs are found.
+ case, ``default`` (*None* if not specified) is returned if no matching
+ XComs are found.
When pulling multiple tasks (i.e. either ``task_id`` or ``map_index``
is
a non-str iterable), a list of matching XComs is returned. Elements in
@@ -2330,9 +2340,10 @@ class TaskInstance(Base, LoggingMixin):
if (task_ids is None or isinstance(task_ids, str)) and not
isinstance(map_indexes, Iterable):
first = query.with_entities(XCom.run_id, XCom.task_id,
XCom.map_index, XCom.value).first()
if first is None: # No matching XCom at all.
- return None
+ return default
if map_indexes is not None or first.map_index < 0:
return XCom.deserialize_value(first)
+
# We're pulling one specific mapped task. Add additional filters to
# make sure all XComs come from one task and run (for task_ids=None
# and include_prior_dates=True), and re-order by map index (reset
@@ -2343,7 +2354,14 @@ class TaskInstance(Base, LoggingMixin):
.order_by(None)
.order_by(XCom.map_index.asc())
)
- return (XCom.deserialize_value(r) for r in query)
+
+ def iter_xcom_values(query):
+ # The session passed to xcom_pull() may die before this is
+ # iterated through, so we need to bind to a new session.
+ for r in query.with_session(settings.Session()):
+ yield XCom.deserialize_value(r)
+
+ return iter_xcom_values(query)
# At this point either task_ids or map_indexes is explicitly
multi-value.
diff --git a/airflow/models/xcom.py b/airflow/models/xcom.py
index 9c0ce3b40c..d67160d1fa 100644
--- a/airflow/models/xcom.py
+++ b/airflow/models/xcom.py
@@ -103,7 +103,9 @@ class BaseXCom(Base, LoggingMixin):
self.value = self.orm_deserialize_value()
def __repr__(self):
- return f'<XCom "{self.key}" ({self.task_id} @ {self.run_id})>'
+ if self.map_index < 0:
+ return f'<XCom "{self.key}" ({self.task_id} @ {self.run_id})>'
+ return f'<XCom "{self.key}" ({self.task_id}[{self.map_index}] @
{self.run_id})>'
@overload
@classmethod
diff --git a/airflow/models/xcom_arg.py b/airflow/models/xcom_arg.py
index 7b6f28d5ca..7e4b274626 100644
--- a/airflow/models/xcom_arg.py
+++ b/airflow/models/xcom_arg.py
@@ -22,6 +22,7 @@ from airflow.models.taskmixin import DAGNode, DependencyMixin
from airflow.models.xcom import XCOM_RETURN_KEY
from airflow.utils.context import Context
from airflow.utils.edgemodifier import EdgeModifier
+from airflow.utils.types import NOTSET
if TYPE_CHECKING:
from airflow.models.operator import Operator
@@ -140,17 +141,13 @@ class XComArg(DependencyMixin):
Pull XCom value for the existing arg. This method is run during
``op.execute()``
in respectable context.
"""
- resolved_value =
context['ti'].xcom_pull(task_ids=[self.operator.task_id], key=str(self.key))
- if not resolved_value:
- if TYPE_CHECKING:
- assert self.operator.dag
+ result = context["ti"].xcom_pull(task_ids=self.operator.task_id,
key=str(self.key), default=NOTSET)
+ if result is NOTSET:
raise AirflowException(
- f'XComArg result from {self.operator.task_id} at
{self.operator.dag.dag_id} '
- f'with key="{self.key}"" is not found!'
+ f'XComArg result from {self.operator.task_id} at
{context["ti"].dag_id} '
+ f'with key="{self.key}" is not found!'
)
- resolved_value = resolved_value[0]
-
- return resolved_value
+ return result
@staticmethod
def apply_upstream_relationship(op: "Operator", arg: Any):
diff --git a/airflow/ti_deps/deps/trigger_rule_dep.py
b/airflow/ti_deps/deps/trigger_rule_dep.py
index f8383b4c7c..1a65467896 100644
--- a/airflow/ti_deps/deps/trigger_rule_dep.py
+++ b/airflow/ti_deps/deps/trigger_rule_dep.py
@@ -19,6 +19,8 @@
from collections import Counter
from typing import TYPE_CHECKING
+from sqlalchemy import func
+
from airflow.ti_deps.dep_context import DepContext
from airflow.ti_deps.deps.base_ti_dep import BaseTIDep
from airflow.utils.session import NEW_SESSION, provide_session
@@ -26,7 +28,9 @@ from airflow.utils.state import State
from airflow.utils.trigger_rule import TriggerRule as TR
if TYPE_CHECKING:
- from airflow.settings import SASession as Session
+ from sqlalchemy.orm import Session
+
+ from airflow.models.taskinstance import TaskInstance
class TriggerRuleDep(BaseTIDep):
@@ -40,7 +44,7 @@ class TriggerRuleDep(BaseTIDep):
IS_TASK_DEP = True
@staticmethod
- def _get_states_count_upstream_ti(ti, finished_tis):
+ def _get_states_count_upstream_ti(task, finished_tis):
"""
This function returns the states of the upstream tis for a specific ti
in order to determine
whether this ti can run in this iteration
@@ -48,7 +52,7 @@ class TriggerRuleDep(BaseTIDep):
:param ti: the ti that we want to calculate deps for
:param finished_tis: all the finished tasks of the dag_run
"""
- counter = Counter(task.state for task in finished_tis if task.task_id
in ti.task.upstream_task_ids)
+ counter = Counter(ti.state for ti in finished_tis if ti.task_id in
task.upstream_task_ids)
return (
counter.get(State.SUCCESS, 0),
counter.get(State.SKIPPED, 0),
@@ -69,7 +73,7 @@ class TriggerRuleDep(BaseTIDep):
return
# see if the task name is in the task upstream for our task
successes, skipped, failed, upstream_failed, done =
self._get_states_count_upstream_ti(
- ti=ti,
finished_tis=dep_context.ensure_finished_tis(ti.get_dagrun(session), session)
+ task=ti.task,
finished_tis=dep_context.ensure_finished_tis(ti.get_dagrun(session), session)
)
yield from self._evaluate_trigger_rule(
@@ -83,6 +87,31 @@ class TriggerRuleDep(BaseTIDep):
session=session,
)
+ @staticmethod
+ def _count_upstreams(ti: "TaskInstance", *, session: "Session"):
+ from airflow.models.taskinstance import TaskInstance
+
+ # Optimization: Don't need to hit the database if no upstreams are
mapped.
+ upstream_task_ids = ti.task.upstream_task_ids
+ if ti.task.dag and not any(ti.task.dag.get_task(tid).is_mapped for tid
in upstream_task_ids):
+ return len(upstream_task_ids)
+
+ # We don't naively count task instances because it is not guaranteed
+ # that all upstreams have been created in the database at this point.
+ # Instead, we look for already-expanded tasks, and add them to the raw
+ # task count without considering mapping.
+ mapped_tis_addition = (
+ session.query(func.count())
+ .filter(
+ TaskInstance.dag_id == ti.dag_id,
+ TaskInstance.run_id == ti.run_id,
+ TaskInstance.task_id.in_(upstream_task_ids),
+ TaskInstance.map_index > 0,
+ )
+ .scalar()
+ )
+ return len(upstream_task_ids) + mapped_tis_addition
+
@provide_session
def _evaluate_trigger_rule(
self,
@@ -112,8 +141,8 @@ class TriggerRuleDep(BaseTIDep):
:param session: database session
"""
task = ti.task
- upstream = len(task.upstream_task_ids)
- trigger_rule = task.trigger_rule
+ upstream = self._count_upstreams(ti, session=session)
+ trigger_rule: TR = task.trigger_rule
upstream_done = done >= upstream
upstream_tasks_state = {
"total": upstream,
diff --git a/tests/models/test_dagrun.py b/tests/models/test_dagrun.py
index 4697354db5..f2a42d40c8 100644
--- a/tests/models/test_dagrun.py
+++ b/tests/models/test_dagrun.py
@@ -981,3 +981,54 @@ def test_mapped_task_upstream_failed(dag_maker, session):
tis, _ = dr.update_state(execute_callbacks=False, session=session)
assert tis == []
assert dr.state == DagRunState.FAILED
+
+
+def test_mapped_task_all_finish_before_downstream(dag_maker, session):
+ result = None
+
+ with dag_maker(session=session) as dag:
+
+ @dag.task
+ def make_list():
+ return [1, 2]
+
+ @dag.task
+ def double(value):
+ return value * 2
+
+ @dag.task
+ def consumer(value):
+ nonlocal result
+ result = list(value)
+
+ consumer(value=double.expand(value=make_list()))
+
+ dr: DagRun = dag_maker.create_dagrun()
+
+ def _task_ids(tis):
+ return [ti.task_id for ti in tis]
+
+ # The first task is always make_list.
+ decision = dr.task_instance_scheduling_decisions(session=session)
+ assert _task_ids(decision.schedulable_tis) == ["make_list"]
+
+ # After make_list is run, double is expanded.
+ decision.schedulable_tis[0].run(verbose=False, session=session)
+ decision = dr.task_instance_scheduling_decisions(session=session)
+ assert _task_ids(decision.schedulable_tis) == ["double", "double"]
+
+ # Running just one of the mapped tis does not make downstream schedulable.
+ decision.schedulable_tis[0].run(verbose=False, session=session)
+ decision = dr.task_instance_scheduling_decisions(session=session)
+ assert _task_ids(decision.schedulable_tis) == ["double"]
+
+ # Downstream is schedulable after all mapped tis are run.
+ decision.schedulable_tis[0].run(verbose=False, session=session)
+ decision = dr.task_instance_scheduling_decisions(session=session)
+ assert _task_ids(decision.schedulable_tis) == ["consumer"]
+
+ # We should be able to get all values aggregated from mapped upstreams.
+ decision.schedulable_tis[0].run(verbose=False, session=session)
+ decision = dr.task_instance_scheduling_decisions(session=session)
+ assert decision.schedulable_tis == []
+ assert result == [2, 4]
diff --git a/tests/models/test_taskinstance.py
b/tests/models/test_taskinstance.py
index e8be37434c..38be50b12d 100644
--- a/tests/models/test_taskinstance.py
+++ b/tests/models/test_taskinstance.py
@@ -2585,3 +2585,35 @@ class TestMappedTaskInstanceReceiveValue:
with out.open() as f:
out_lines = [line.strip() for line in f]
assert out_lines == ["hello FOO", "goodbye FOO", "hello BAR", "goodbye
BAR"]
+
+
[email protected]("airflow.models.taskinstance.XCom.deserialize_value",
side_effect=XCom.deserialize_value)
+def
test_ti_xcom_pull_on_mapped_operator_return_lazy_iterator(mock_deserialize_value,
dag_maker, session):
+ """Ensure we access XCom lazily when pulling from a mapped operator."""
+ with dag_maker(dag_id="test_xcom", session=session):
+ task_1 = DummyOperator.partial(task_id="task_1").expand()
+ DummyOperator(task_id="task_2")
+
+ dagrun = dag_maker.create_dagrun()
+
+ ti_1_0 = dagrun.get_task_instance("task_1", session=session)
+ ti_1_0.map_index = 0
+ ti_1_1 = session.merge(TaskInstance(task_1, run_id=dagrun.run_id,
map_index=1, state=ti_1_0.state))
+ session.flush()
+
+ ti_1_0.xcom_push(key=XCOM_RETURN_KEY, value="a", session=session)
+ ti_1_1.xcom_push(key=XCOM_RETURN_KEY, value="b", session=session)
+
+ ti_2 = dagrun.get_task_instance("task_2", session=session)
+
+ # Simply pulling the joined XCom value should not deserialize.
+ joined = ti_2.xcom_pull("task_1", session=session)
+ assert mock_deserialize_value.call_count == 0
+
+ # Only when we go through the iterator does deserialization happen.
+ assert next(joined) == "a"
+ assert mock_deserialize_value.call_count == 1
+ assert next(joined) == "b"
+ assert mock_deserialize_value.call_count == 2
+ with pytest.raises(StopIteration):
+ next(joined)
diff --git a/tests/ti_deps/deps/test_trigger_rule_dep.py
b/tests/ti_deps/deps/test_trigger_rule_dep.py
index cf2cada840..14dc15dc3b 100644
--- a/tests/ti_deps/deps/test_trigger_rule_dep.py
+++ b/tests/ti_deps/deps/test_trigger_rule_dep.py
@@ -41,7 +41,7 @@ def get_task_instance(session, dag_maker):
task_id='test_task', trigger_rule=trigger_rule,
start_date=datetime(2015, 1, 1)
)
if upstream_task_ids:
- task.upstream_task_ids.update(upstream_task_ids)
+ [DummyOperator(task_id=task_id) for task_id in
upstream_task_ids] >> task
dr = dag_maker.create_dagrun()
ti = dr.task_instances[0]
ti.task = task
@@ -662,10 +662,10 @@ class TestTriggerRuleDep:
# check handling with cases that tasks are triggered from backfill
with no finished tasks
finished_tis = DepContext().ensure_finished_tis(ti_op2.dag_run,
session)
- assert get_states_count_upstream_ti(finished_tis=finished_tis,
ti=ti_op2) == (1, 0, 0, 0, 1)
+ assert get_states_count_upstream_ti(finished_tis=finished_tis,
task=op2) == (1, 0, 0, 0, 1)
finished_tis = dr.get_task_instances(state=State.finished,
session=session)
- assert get_states_count_upstream_ti(finished_tis=finished_tis,
ti=ti_op4) == (1, 0, 1, 0, 2)
- assert get_states_count_upstream_ti(finished_tis=finished_tis,
ti=ti_op5) == (2, 0, 1, 0, 3)
+ assert get_states_count_upstream_ti(finished_tis=finished_tis,
task=op4) == (1, 0, 1, 0, 2)
+ assert get_states_count_upstream_ti(finished_tis=finished_tis,
task=op5) == (2, 0, 1, 0, 3)
dr.update_state()
assert State.SUCCESS == dr.state