Skip to content

Commit 3b9a97b

Browse files
committed
Fix clearing with upstream and downstream selecting unrelated tasks
Clearing a task in a specific Dag run with both upstream and downstream enabled resolved the downstream relatives from the selected tasks plus their upstream tasks, so every other descendant of those upstream tasks was selected too. In a Dag whose branches share a root task this cleared the whole run, and selecting one map index of a mapped task cleared all of its map indexes. Each direction has to be resolved from the requested tasks alone, as clearing without a specific Dag run already does.
1 parent fe58952 commit 3b9a97b

2 files changed

Lines changed: 51 additions & 7 deletions

File tree

‎airflow-core/src/airflow/api_fastapi/core_api/routes/public/task_instances.py‎

Lines changed: 12 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@
1818
from __future__ import annotations
1919

2020
from collections import defaultdict
21-
from collections.abc import Sequence
21+
from collections.abc import Collection, Sequence
2222
from typing import Annotated, Literal, cast
2323
from uuid import UUID
2424

@@ -1090,19 +1090,19 @@ def post_clear_task_instances(
10901090
# Unmapped tasks are expressed in their task_ids (without map_indexes)
10911091
normal_task_ids = {t for t in task_markers_to_clear if not isinstance(t, tuple)}
10921092

1093-
def _collect_relatives(run_id: str, direction: Literal["upstream", "downstream"]) -> None:
1093+
def _find_relatives(
1094+
run_id: str, direction: Literal["upstream", "downstream"]
1095+
) -> Collection[str | tuple[str, int]]:
10941096
from airflow.models.taskinstance import find_relevant_relatives
10951097

1096-
relevant_relatives = find_relevant_relatives(
1098+
return find_relevant_relatives(
10971099
normal_task_ids,
10981100
mapped_tasks_tuples,
10991101
dag=dag,
11001102
run_id=run_id,
11011103
direction=direction,
11021104
session=session,
11031105
)
1104-
normal_task_ids.update(t for t in relevant_relatives if not isinstance(t, tuple))
1105-
mapped_tasks_tuples.update(t for t in relevant_relatives if isinstance(t, tuple))
11061106

11071107
# We can't easily calculate upstream/downstream map indexes when not
11081108
# working for a specific dag run. It's possible by looking at the runs
@@ -1119,10 +1119,15 @@ def _collect_relatives(run_id: str, direction: Literal["upstream", "downstream"]
11191119
)
11201120
normal_task_ids.update(partial_dag.task_dict)
11211121
else:
1122+
# Both directions must start from the requested tasks only. Merging upstream results
1123+
# before the downstream pass would also select every descendant of those upstream tasks.
1124+
relatives: set[str | tuple[str, int]] = set()
11221125
if upstream:
1123-
_collect_relatives(dag_run_id, "upstream")
1126+
relatives.update(_find_relatives(dag_run_id, "upstream"))
11241127
if downstream:
1125-
_collect_relatives(dag_run_id, "downstream")
1128+
relatives.update(_find_relatives(dag_run_id, "downstream"))
1129+
normal_task_ids.update(t for t in relatives if not isinstance(t, tuple))
1130+
mapped_tasks_tuples.update(t for t in relatives if isinstance(t, tuple))
11261131

11271132
task_markers_to_clear = [
11281133
*normal_task_ids,

‎airflow-core/tests/unit/api_fastapi/core_api/routes/public/test_task_instances.py‎

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5443,6 +5443,45 @@ def test_clear_by_task_group_id_handles_group_larger_than_page_size(
54435443
assert response.status_code == 200
54445444
assert response.json()["total_entries"] == group_size
54455445

5446+
@pytest.mark.parametrize(
5447+
("mapped", "task_id", "expected"),
5448+
[
5449+
pytest.param(False, "a", [("a", -1), ("b", -1), ("root", -1)], id="unmapped"),
5450+
pytest.param(True, ["a", 0], [("a", 0), ("b", -1), ("root", -1)], id="mapped"),
5451+
],
5452+
)
5453+
def test_clear_with_upstream_and_downstream_excludes_unrelated_tasks(
5454+
self, test_client, dag_maker, session, mapped, task_id, expected
5455+
):
5456+
"""Upstream + downstream must not include other descendants of the upstream tasks."""
5457+
dag_id = "clear_upstream_and_downstream"
5458+
with dag_maker(session=session, dag_id=dag_id, start_date=DEFAULT_DATETIME_1, serialized=True):
5459+
root = BaseOperator(task_id="root")
5460+
if mapped:
5461+
a = MockOperator.partial(task_id="a").expand(arg2=["x", "y", "z"])
5462+
else:
5463+
a = BaseOperator(task_id="a")
5464+
b = BaseOperator(task_id="b")
5465+
other = BaseOperator(task_id="other")
5466+
root >> a >> b
5467+
root >> other
5468+
dr = dag_maker.create_dagrun(run_id="run1", logical_date=DEFAULT_DATETIME_1)
5469+
5470+
response = test_client.post(
5471+
f"/dags/{dag_id}/clearTaskInstances",
5472+
json={
5473+
"dry_run": True,
5474+
"only_failed": False,
5475+
"dag_run_id": dr.run_id,
5476+
"task_ids": [task_id],
5477+
"include_upstream": True,
5478+
"include_downstream": True,
5479+
},
5480+
)
5481+
assert response.status_code == 200
5482+
response_tis = response.json()["task_instances"]
5483+
assert sorted((ti["task_id"], ti["map_index"]) for ti in response_tis) == expected
5484+
54465485

54475486
class TestGetTaskInstanceTries(TestTaskInstanceEndpoint):
54485487
def test_history_without_dag_version_serializes_null(self, test_client, session):

0 commit comments

Comments
 (0)