This is an automated email from the ASF dual-hosted git repository.

henry3260 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 43129ecb633 Fix clearing with upstream and downstream selecting 
unrelated tasks (#74548)
43129ecb633 is described below

commit 43129ecb63355c8244df1ac67b392bc8a775f2fa
Author: Kevin Yang <[email protected]>
AuthorDate: Tue Oct 13 10:19:27 2026 -0400

    Fix clearing with upstream and downstream selecting unrelated tasks (#74548)
    
    * 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.
    
    * Pass the requested tasks to the relative lookup explicitly
    
    Both directions must be resolved from the same requested tasks.
    Passing the sets in makes that visible where the lookups are made,
    instead of relying on a helper that reads sets which are updated
    after it returns.
    
    * Fix clearing in Dags with a loop selecting unrelated tasks
    
    Any Dag that contains a loop is cleared through the loop-aware
    selection, which still resolved downstream relatives from the selected
    tasks plus their upstream tasks. Clearing with both upstream and
    downstream therefore still selected unrelated tasks in those Dags.
    
    For a task inside a loop it also reached the gate of every iteration,
    so the clear replaced the selected iteration and re-ran the loop from
    its first iteration, and marking the task with both options changed
    every iteration.
    
    * Give the upstream and downstream mapped loop clear its own test
    
    The test name says clearing from the predecessor selects every mapped
    occurrence in the loop. That still holds downstream only, but with
    upstream and downstream the other map index is now deliberately left
    out, so that case no longer fits the name.
---
 .../core_api/routes/public/task_instances.py       |  33 ++++---
 airflow-core/src/airflow/models/taskinstance.py    |   5 +-
 .../core_api/routes/public/test_task_instances.py  | 103 +++++++++++++++++++++
 airflow-core/tests/unit/models/test_cleartasks.py  |  85 +++++++++++++++--
 4 files changed, 204 insertions(+), 22 deletions(-)

diff --git 
a/airflow-core/src/airflow/api_fastapi/core_api/routes/public/task_instances.py 
b/airflow-core/src/airflow/api_fastapi/core_api/routes/public/task_instances.py
index f50c86b812a..88715c445b2 100644
--- 
a/airflow-core/src/airflow/api_fastapi/core_api/routes/public/task_instances.py
+++ 
b/airflow-core/src/airflow/api_fastapi/core_api/routes/public/task_instances.py
@@ -18,7 +18,7 @@
 from __future__ import annotations
 
 from collections import defaultdict
-from collections.abc import Sequence
+from collections.abc import Collection, Iterable, Sequence
 from typing import Annotated, Literal, cast
 from uuid import UUID
 
@@ -145,6 +145,7 @@ from airflow.models.taskinstance import (
     TaskInstance as TI,
     apply_loop_clear_scope,
     clear_task_instances,
+    find_relevant_relatives,
     select_loop_clear_scope,
 )
 from airflow.ti_deps.dep_context import DepContext
@@ -1090,19 +1091,20 @@ def post_clear_task_instances(
         # Unmapped tasks are expressed in their task_ids (without map_indexes)
         normal_task_ids = {t for t in task_markers_to_clear if not 
isinstance(t, tuple)}
 
-        def _collect_relatives(run_id: str, direction: Literal["upstream", 
"downstream"]) -> None:
-            from airflow.models.taskinstance import find_relevant_relatives
-
-            relevant_relatives = find_relevant_relatives(
-                normal_task_ids,
-                mapped_tasks_tuples,
+        def _find_relatives(
+            run_id: str,
+            normal_tasks: Iterable[str],
+            mapped_tasks: Iterable[tuple[str, int]],
+            direction: Literal["upstream", "downstream"],
+        ) -> Collection[str | tuple[str, int]]:
+            return find_relevant_relatives(
+                normal_tasks,
+                mapped_tasks,
                 dag=dag,
                 run_id=run_id,
                 direction=direction,
                 session=session,
             )
-            normal_task_ids.update(t for t in relevant_relatives if not 
isinstance(t, tuple))
-            mapped_tasks_tuples.update(t for t in relevant_relatives if 
isinstance(t, tuple))
 
         # We can't easily calculate upstream/downstream map indexes when not
         # working for a specific dag run. It's possible by looking at the runs
@@ -1119,10 +1121,19 @@ def post_clear_task_instances(
                 )
                 normal_task_ids.update(partial_dag.task_dict)
         else:
+            # Both directions must start from the requested tasks only. 
Merging upstream results
+            # before the downstream pass would also select every descendant of 
those upstream tasks.
+            relatives: set[str | tuple[str, int]] = set()
             if upstream:
-                _collect_relatives(dag_run_id, "upstream")
+                relatives.update(
+                    _find_relatives(dag_run_id, normal_task_ids, 
mapped_tasks_tuples, "upstream")
+                )
             if downstream:
-                _collect_relatives(dag_run_id, "downstream")
+                relatives.update(
+                    _find_relatives(dag_run_id, normal_task_ids, 
mapped_tasks_tuples, "downstream")
+                )
+            normal_task_ids.update(t for t in relatives if not isinstance(t, 
tuple))
+            mapped_tasks_tuples.update(t for t in relatives if isinstance(t, 
tuple))
 
         task_markers_to_clear = [
             *normal_task_ids,
diff --git a/airflow-core/src/airflow/models/taskinstance.py 
b/airflow-core/src/airflow/models/taskinstance.py
index fe68cfe0e48..e594ba5407a 100644
--- a/airflow-core/src/airflow/models/taskinstance.py
+++ b/airflow-core/src/airflow/models/taskinstance.py
@@ -785,10 +785,13 @@ def select_loop_clear_scope(
                 matched_relatives[key] = tuple(matches)
             retry.update((other.id, other) for other in matched_relatives[key])
 
+    # Both directions start from the selection only. Walking downstream from 
upstream relatives
+    # would also select their other descendants, including gates whose later 
passes then get archived.
+    seeds = tuple(retry.values())
     for is_upstream, enabled in ((True, upstream), (False, downstream)):
         if not enabled:
             continue
-        for ti in tuple(retry.values()):
+        for ti in seeds:
             task = resolver.get_task(dag_id, run_id, ti.task_id, 
dag_version_id=ti.dag_version_id)
             add_relatives(ti, task.get_flat_relatives(upstream=is_upstream))
     if include_setups_and_teardowns:
diff --git 
a/airflow-core/tests/unit/api_fastapi/core_api/routes/public/test_task_instances.py
 
b/airflow-core/tests/unit/api_fastapi/core_api/routes/public/test_task_instances.py
index 9c0e5e6d664..952ed417fd9 100644
--- 
a/airflow-core/tests/unit/api_fastapi/core_api/routes/public/test_task_instances.py
+++ 
b/airflow-core/tests/unit/api_fastapi/core_api/routes/public/test_task_instances.py
@@ -5443,6 +5443,53 @@ class 
TestPostClearTaskInstances(TestTaskInstanceEndpoint):
         assert response.status_code == 200
         assert response.json()["total_entries"] == group_size
 
+    @pytest.mark.parametrize(
+        ("mapped", "task_id", "expected"),
+        [
+            pytest.param(False, "a", [("a", -1), ("b", -1), ("root", -1)], 
id="unmapped"),
+            pytest.param(True, ["a", 0], [("a", 0), ("b", -1), ("root", -1)], 
id="mapped"),
+        ],
+    )
+    @pytest.mark.parametrize("with_loop", [False, True], ids=["without-loop", 
"with-unrelated-loop"])
+    def test_clear_with_upstream_and_downstream_excludes_unrelated_tasks(
+        self, test_client, dag_maker, session, mapped, task_id, expected, 
with_loop
+    ):
+        """Upstream + downstream must not include other descendants of the 
upstream tasks."""
+
+        @task_group
+        def body():
+            BaseOperator(task_id="member")
+
+        dag_id = "clear_upstream_and_downstream"
+        with dag_maker(session=session, dag_id=dag_id, 
start_date=DEFAULT_DATETIME_1, serialized=True):
+            if with_loop:
+                create_loop(body, max_iterations=2)
+            root = BaseOperator(task_id="root")
+            if mapped:
+                a = MockOperator.partial(task_id="a").expand(arg2=["x", "y", 
"z"])
+            else:
+                a = BaseOperator(task_id="a")
+            b = BaseOperator(task_id="b")
+            other = BaseOperator(task_id="other")
+            root >> a >> b
+            root >> other
+        dr = dag_maker.create_dagrun(run_id="run1", 
logical_date=DEFAULT_DATETIME_1)
+
+        response = test_client.post(
+            f"/dags/{dag_id}/clearTaskInstances",
+            json={
+                "dry_run": True,
+                "only_failed": False,
+                "dag_run_id": dr.run_id,
+                "task_ids": [task_id],
+                "include_upstream": True,
+                "include_downstream": True,
+            },
+        )
+        assert response.status_code == 200
+        response_tis = response.json()["task_instances"]
+        assert sorted((ti["task_id"], ti["map_index"]) for ti in response_tis) 
== expected
+
 
 class TestGetTaskInstanceTries(TestTaskInstanceEndpoint):
     def test_history_without_dag_version_serializes_null(self, test_client, 
session):
@@ -5900,6 +5947,62 @@ class 
TestRegionalTaskStateControls(TestTaskInstanceEndpoint):
         )
         assert current_last.state == (State.FAILED if dry_run else None)
 
+    def test_loop_mark_with_upstream_and_downstream_excludes_unrelated_tasks(
+        self, test_client, dag_maker, session
+    ):
+        @task_group
+        def body():
+            prepare = MockOperator(task_id="prepare")
+            combine = MockOperator(task_id="combine")
+            prepare >> MockOperator(task_id="process") >> combine
+            prepare >> MockOperator(task_id="audit") >> combine
+
+        with dag_maker("mark_loop_upstream_and_downstream", serialized=True):
+            start = MockOperator(task_id="start")
+            loop = create_loop(body, max_iterations=3)
+            start >> MockOperator(task_id="report")
+            start >> loop >> MockOperator(task_id="finish")
+        dr = dag_maker.create_dagrun()
+        root = 
session.scalar(select(DynamicRegion).where(DynamicRegion.node_id == 
loop.group_id))
+        for index in (1, 2):
+            for loop_task in loop.iter_tasks():
+                session.add(
+                    TaskInstance(
+                        task=loop_task,
+                        run_id=dr.run_id,
+                        dag_version_id=dr.created_dag_version_id,
+                        region_id=root.id,
+                        region_index=index,
+                    )
+                )
+        session.flush()
+        for ti in dr.get_task_instances(session=session):
+            ti.set_state(State.SUCCESS, session=session)
+        session.commit()
+
+        response = test_client.patch(
+            
f"/dags/{dr.dag_id}/dagRuns/{dr.run_id}/taskInstances/body.process/dry_run",
+            json={
+                "new_state": "failed",
+                "region_id": str(root.id),
+                "region_index": 1,
+                "include_upstream": True,
+                "include_downstream": True,
+            },
+        )
+
+        assert response.status_code == 200, response.text
+        assert {
+            (item["task_id"], item.get("region_index")) for item in 
response.json()["task_instances"]
+        } == {
+            ("start", None),
+            ("body.prepare", 1),
+            ("body.process", 1),
+            ("body.combine", 1),
+            (loop.gate_task_id, 1),
+            ("finish", None),
+        }
+
     def test_loop_mark_without_coordinates_requires_a_region_and_index(self, 
test_client, loop_instances):
         dr, loop, root, tis = loop_instances
 
diff --git a/airflow-core/tests/unit/models/test_cleartasks.py 
b/airflow-core/tests/unit/models/test_cleartasks.py
index f004638ea60..95d52e56301 100644
--- a/airflow-core/tests/unit/models/test_cleartasks.py
+++ b/airflow-core/tests/unit/models/test_cleartasks.py
@@ -2164,6 +2164,50 @@ def 
test_loop_upstream_scope_stays_in_iteration_before_downstream_expansion(loop
     }
 
 
+def 
test_loop_upstream_and_downstream_scope_excludes_other_descendants_of_upstream_tasks(dag_maker,
 session):
+    @task_group
+    def body():
+        prepare = EmptyOperator(task_id="prepare")
+        combine = EmptyOperator(task_id="combine")
+        prepare >> EmptyOperator(task_id="process") >> combine
+        prepare >> EmptyOperator(task_id="audit") >> combine
+
+    with dag_maker(serialized=True):
+        start = EmptyOperator(task_id="start")
+        loop = create_loop(body, max_iterations=3)
+        start >> EmptyOperator(task_id="report")
+        start >> loop >> EmptyOperator(task_id="finish")
+    dr = dag_maker.create_dagrun()
+    root = session.scalar(select(DynamicRegion).where(DynamicRegion.node_id == 
loop.group_id))
+    for index in (1, 2):
+        for loop_task in loop.iter_tasks():
+            session.add(
+                TaskInstance(
+                    task=loop_task,
+                    run_id=dr.run_id,
+                    dag_version_id=dr.created_dag_version_id,
+                    region_id=root.id,
+                    region_index=index,
+                )
+            )
+    session.flush()
+    tis = {(ti.task_id, ti.region_index): ti for ti in 
dr.get_task_instances(session=session)}
+
+    scope = select_loop_clear_scope([tis["body.process", 1]], upstream=True, 
downstream=True, session=session)
+
+    assert {key for key, ti in tis.items() if ti.id in scope.retry_ids} == {
+        ("start", -1),
+        ("body.prepare", 1),
+        ("body.process", 1),
+        ("body.combine", 1),
+        (loop.gate_task_id, 1),
+        ("finish", -1),
+    }
+    assert {key for key, ti in tis.items() if ti.id in scope.archive_ids} == {
+        (loop_task.task_id, 2) for loop_task in loop.iter_tasks()
+    }
+
+
 def test_outside_seed_upstream_selects_all_current_loop_occurrences(loop_run, 
session):
     dr, dag, loop, root, tis, iteration = loop_run
     outside = next(ti for ti in tis if ti.task_id == "outside")
@@ -2174,8 +2218,8 @@ def 
test_outside_seed_upstream_selects_all_current_loop_occurrences(loop_run, se
     assert scope.archive_ids == {ti.id for ti in tis if ti.task_id != 
"outside" and iteration(ti) > 0}
 
 
[email protected]("upstream", [False, True])
-def test_outside_predecessor_selects_all_mapped_loop_occurrences(dag_maker, 
session, upstream):
[email protected]
+def mapped_loop_after_predecessor(dag_maker, session):
     @task_group
     def body():
         PythonOperator.partial(task_id="process", 
python_callable=list).expand(op_kwargs=[{}, {}])
@@ -2208,23 +2252,44 @@ def 
test_outside_predecessor_selects_all_mapped_loop_occurrences(dag_maker, sess
                 )
             )
     session.flush()
-    tis = dr.get_task_instances(session=session)
-    seed = next(
-        ti
-        for ti in tis
-        if ti.task_id == ("body.process" if upstream else "before")
-        and ti.region_index == (0 if upstream else -1)
+    return loop, child, dr.get_task_instances(session=session)
+
+
+def 
test_outside_predecessor_selects_all_mapped_loop_occurrences(mapped_loop_after_predecessor,
 session):
+    loop, child, tis = mapped_loop_after_predecessor
+    before = next(ti for ti in tis if ti.task_id == "before")
+
+    scope = select_loop_clear_scope(
+        [before],
+        upstream=False,
+        downstream=True,
+        later_loop_iterations=False,
+        session=session,
     )
 
+    assert scope.retry_ids == {ti.id for ti in tis}
+    assert not scope.archive_ids
+
+
+def test_loop_upstream_and_downstream_scope_excludes_other_map_indexes(
+    mapped_loop_after_predecessor, session
+):
+    loop, child, tis = mapped_loop_after_predecessor
+    seed = next(ti for ti in tis if ti.region_id == child.id and 
ti.region_index == 0)
+
     scope = select_loop_clear_scope(
         [seed],
-        upstream=upstream,
+        upstream=True,
         downstream=True,
         later_loop_iterations=False,
         session=session,
     )
 
-    assert scope.retry_ids == {ti.id for ti in tis}
+    assert scope.retry_ids == {seed.id} | {
+        ti.id
+        for ti in tis
+        if ti.task_id == "before" or (ti.task_id == loop.gate_task_id and 
ti.region_index == 1)
+    }
     assert not scope.archive_ids
 
 

Reply via email to