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

pierrejeambrun pushed a commit to branch v3-3-test
in repository https://gitbox.apache.org/repos/asf/airflow.git


The following commit(s) were added to refs/heads/v3-3-test by this push:
     new ff75d24f032 Fix grid/graph view topological sort for group-level and 
cross-group dependencies (#69933) (#70591)
ff75d24f032 is described below

commit ff75d24f0320565942508527c4806b99812e535d
Author: Jason(Zhe-You) Liu <[email protected]>
AuthorDate: Tue Jul 28 21:17:04 2026 +0800

    Fix grid/graph view topological sort for group-level and cross-group 
dependencies (#69933) (#70591)
    
    * Fix grid/graph view topological sort for group-level and cross-group deps
    
    TaskGroup._project_child_deps only looked at a group's own 
upstream_task_ids,
    which stays empty for a direct group-to-group dependency (list or individual
    `>>`) and for a task-level dependency that crosses into another group's 
entry
    task. Both cases sorted the group as if it had no upstream at all.
    
    Now also pulls in the group's upstream_group_ids and its root tasks' 
upstream
    task ids before projecting sibling dependencies. Applied to both the
    serialization-layer sort and the mirrored design-time sort in task-sdk.
    
    closes: #65291
    
    Related: apache/airflow#67964 (closed for inactivity, written against the
    topological_sort implementation before PR #67288/#67688 rewrote it) and
    apache/airflow#65639 (draft, same issue, also predates the rewrite).
    
    * Address review feedback: cache get_task_group_dict, describe test intent 
not issue numbers
    
    viiccwen pointed out that fetching the group map inside topological_sort() 
rebuilds
    the whole DAG's group tree on every nested group's own call, turning a 
render with
    G groups into an O(G^2) cost. get_task_group_dict() is now memoized per DAG 
instance
    (kept behind a small private helper since methodtools.lru_cache has no type 
stubs and
    would otherwise widen the public method's return type to Any for every 
caller).
    
    Also reworded test comments/docstrings that cited issue numbers to describe 
what's
    actually being verified instead.
    
    * Hoist common logic into shared lib
    
    * Remove caching on get_task_group_dict
    
    * Remove stale get_task_group_dict cache tests
    
    The cache these tests asserted was removed in the previous commit, so the
    identity check and the _get_task_group_dict_cached.cache_info() assertions
    no longer apply.
    
    * Add call-level task group memo to reduce calc
    
    * Tidy Typy
    
    * Add test for task group memoing
    
    ---------
    
    Co-authored-by: TP <[email protected]>
    Co-authored-by: LIU ZHE YOU <[email protected]>
    (cherry picked from commit d7aa92913bfed7b37081a3962fb6072053fc8d75)
    
    # Conflicts:
    #       
airflow-core/src/airflow/api_fastapi/core_api/services/ui/task_group.py
    
    Co-authored-by: Hemkumar Chheda <[email protected]>
---
 .../airflow/api_fastapi/core_api/routes/ui/grid.py |  19 ++-
 .../api_fastapi/core_api/routes/ui/structure.py    |   6 +-
 .../api_fastapi/core_api/services/ui/grid.py       |   7 +-
 .../api_fastapi/core_api/services/ui/task_group.py |  56 ++++++---
 .../airflow/serialization/definitions/taskgroup.py |  25 ++--
 airflow-core/tests/unit/utils/test_task_group.py   | 128 +++++++++++++++++++--
 shared/dagnode/src/airflow_shared/dagnode/node.py  |  38 ++++++
 task-sdk/src/airflow/sdk/definitions/taskgroup.py  |  23 ++--
 .../tests/task_sdk/definitions/test_taskgroup.py   |   3 +-
 9 files changed, 259 insertions(+), 46 deletions(-)

diff --git a/airflow-core/src/airflow/api_fastapi/core_api/routes/ui/grid.py 
b/airflow-core/src/airflow/api_fastapi/core_api/routes/ui/grid.py
index 121cc79ec64..9bbe7e1dc03 100644
--- a/airflow-core/src/airflow/api_fastapi/core_api/routes/ui/grid.py
+++ b/airflow-core/src/airflow/api_fastapi/core_api/routes/ui/grid.py
@@ -200,15 +200,22 @@ def get_dag_structure(
     run_ids = list(session.scalars(dag_runs_select_filter))
 
     task_group_sort = get_task_group_children_getter()
+    latest_group_dict = latest_dag.task_group.get_task_group_dict()
     if not run_ids:
-        nodes = [task_group_to_dict_grid(x) for x in 
task_group_sort(latest_dag.task_group)]
+        nodes = [
+            task_group_to_dict_grid(x, group_dict=latest_group_dict)
+            for x in task_group_sort(latest_dag.task_group, latest_group_dict)
+        ]
         return [GridNodeResponse(**n) for n in nodes]
 
     # Process and merge the latest serdag first
     merged_nodes: list[dict[str, Any]] = []
-    nodes = [task_group_to_dict_grid(x) for x in 
task_group_sort(latest_dag.task_group)]
+    nodes = [
+        task_group_to_dict_grid(x, group_dict=latest_group_dict)
+        for x in task_group_sort(latest_dag.task_group, latest_group_dict)
+    ]
     _merge_node_dicts(merged_nodes, nodes)
-    del latest_dag
+    del latest_dag, latest_group_dict
 
     # Process serdags one by one and merge immediately to reduce memory usage.
     # Use yield_per() for streaming results and expunge each serdag after 
processing
@@ -243,7 +250,11 @@ def get_dag_structure(
                 depth=depth,
             )
         # Merge immediately instead of collecting all Dags in memory
-        nodes = [task_group_to_dict_grid(x) for x in 
task_group_sort(filtered_dag.task_group)]
+        filtered_group_dict = filtered_dag.task_group.get_task_group_dict()
+        nodes = [
+            task_group_to_dict_grid(x, group_dict=filtered_group_dict)
+            for x in task_group_sort(filtered_dag.task_group, 
filtered_group_dict)
+        ]
         _merge_node_dicts(merged_nodes, nodes)
 
         session.expunge(serdag)  # to allow garbage collection
diff --git 
a/airflow-core/src/airflow/api_fastapi/core_api/routes/ui/structure.py 
b/airflow-core/src/airflow/api_fastapi/core_api/routes/ui/structure.py
index 597f44db424..a5cb9d1d8d1 100644
--- a/airflow-core/src/airflow/api_fastapi/core_api/routes/ui/structure.py
+++ b/airflow-core/src/airflow/api_fastapi/core_api/routes/ui/structure.py
@@ -96,7 +96,11 @@ def structure_data(
             depth=depth,
         )
 
-    nodes = [task_group_to_dict(child) for child in 
dag.task_group.topological_sort()]
+    group_dict = dag.task_group.get_task_group_dict()
+    nodes = [
+        task_group_to_dict(child, group_dict=group_dict)
+        for child in dag.task_group.topological_sort(group_dict=group_dict)
+    ]
     edges = dag_edges(dag)
 
     data = {
diff --git a/airflow-core/src/airflow/api_fastapi/core_api/services/ui/grid.py 
b/airflow-core/src/airflow/api_fastapi/core_api/services/ui/grid.py
index bff2fd66296..4af64fc45db 100644
--- a/airflow-core/src/airflow/api_fastapi/core_api/services/ui/grid.py
+++ b/airflow-core/src/airflow/api_fastapi/core_api/services/ui/grid.py
@@ -140,6 +140,7 @@ def _find_aggregates(
     node: SerializedTaskGroup | SerializedBaseOperator | TaskMap,
     parent_node: SerializedTaskGroup | SerializedBaseOperator | TaskMap | None,
     ti_details: Mapping[str, GridNodeAgg],
+    group_dict: dict[str | None, SerializedTaskGroup] | None = None,
 ) -> Iterable[tuple[dict[str, Any], GridNodeAgg]]:
     """Recursively fill the Task Group Map."""
     node_id = node.node_id
@@ -166,10 +167,12 @@ def _find_aggregates(
 
         return
     if isinstance(node, SerializedTaskGroup):
+        if group_dict is None:
+            group_dict = node.dag.task_group.get_task_group_dict()
         children_summary = GridNodeAgg()
-        for child in get_task_group_children_getter()(node):
+        for child in get_task_group_children_getter()(node, group_dict):
             for child_node, child_summary in _find_aggregates(
-                node=child, parent_node=node, ti_details=ti_details
+                node=child, parent_node=node, ti_details=ti_details, 
group_dict=group_dict
             ):
                 if child_node["parent_id"] == node_id:
                     children_summary.merge(child_summary)
diff --git 
a/airflow-core/src/airflow/api_fastapi/core_api/services/ui/task_group.py 
b/airflow-core/src/airflow/api_fastapi/core_api/services/ui/task_group.py
index 47d49757e69..f48af6d6258 100644
--- a/airflow-core/src/airflow/api_fastapi/core_api/services/ui/task_group.py
+++ b/airflow-core/src/airflow/api_fastapi/core_api/services/ui/task_group.py
@@ -19,25 +19,29 @@
 
 from __future__ import annotations
 
-from collections.abc import Callable
 from functools import cache
-from operator import methodcaller
+from typing import TYPE_CHECKING
 
 from airflow.configuration import conf
 from airflow.serialization.definitions.baseoperator import 
SerializedBaseOperator
 from airflow.serialization.definitions.mappedoperator import 
SerializedMappedOperator, is_mapped
 
+if TYPE_CHECKING:
+    from collections.abc import Callable
+    from typing import Any
+
+    from airflow.serialization.definitions.taskgroup import SerializedTaskGroup
+
 
 @cache
 def get_task_group_children_getter() -> Callable:
     """Get the Task Group Children Getter for the Dag."""
-    sort_order = conf.get("api", "grid_view_sorting_order")
-    if sort_order == "topological":
-        return methodcaller("topological_sort")
-    return methodcaller("hierarchical_alphabetical_sort")
+    if conf.get("api", "grid_view_sorting_order") == "topological":
+        return lambda task_group, group_dict=None: 
task_group.topological_sort(group_dict=group_dict)
+    return lambda task_group, group_dict=None: 
task_group.hierarchical_alphabetical_sort()
 
 
-def task_group_to_dict(task_item_or_group, parent_group_is_mapped=False):
+def task_group_to_dict(task_item_or_group, *, group_dict=None, 
parent_group_is_mapped=False):
     """Create a nested dict representation of this TaskGroup and its children 
used to construct the Graph."""
     if isinstance(task := task_item_or_group, (SerializedBaseOperator, 
SerializedMappedOperator)):
         # we explicitly want the short task ID here, not the full doted 
notation if in a group
@@ -57,10 +61,16 @@ def task_group_to_dict(task_item_or_group, 
parent_group_is_mapped=False):
         return node_operator
 
     task_group = task_item_or_group
+    if group_dict is None:
+        group_dict = task_group.dag.task_group.get_task_group_dict()
     mapped = is_mapped(task_group)
     children = [
-        task_group_to_dict(child, 
parent_group_is_mapped=parent_group_is_mapped or mapped)
-        for child in get_task_group_children_getter()(task_group)
+        task_group_to_dict(
+            child,
+            parent_group_is_mapped=parent_group_is_mapped or mapped,
+            group_dict=group_dict,
+        )
+        for child in get_task_group_children_getter()(task_group, group_dict)
     ]
 
     if task_group.upstream_group_ids or task_group.upstream_task_ids:
@@ -82,8 +92,22 @@ def task_group_to_dict(task_item_or_group, 
parent_group_is_mapped=False):
     return node
 
 
-def task_group_to_dict_grid(task_item_or_group, parent_group_is_mapped=False):
-    """Create a nested dict representation of this TaskGroup and its children 
used to construct the Grid."""
+def task_group_to_dict_grid(
+    task_item_or_group,
+    *,
+    group_dict: dict[str | None, SerializedTaskGroup] | None = None,
+    parent_group_is_mapped: bool = False,
+) -> dict[str, Any]:
+    """
+    Create a nested dict representation of this TaskGroup and its children 
used to construct the Grid.
+
+    :param group_dict: A ``{group_id: group}`` map used to resolve cross-group
+        dependencies. Built once at the top of a render and threaded through 
the
+        recursion so nested groups reuse it.
+    :param parent_group_is_mapped: Whether an ancestor task group is mapped, 
propagated to children.
+    """
+    node: dict[str, Any]
+
     if isinstance(task := task_item_or_group, (SerializedMappedOperator, 
SerializedBaseOperator)):
         mapped = None
         if parent_group_is_mapped or is_mapped(task):
@@ -105,11 +129,17 @@ def task_group_to_dict_grid(task_item_or_group, 
parent_group_is_mapped=False):
         return node
 
     task_group = task_item_or_group
+    if group_dict is None:
+        group_dict = task_group.dag.task_group.get_task_group_dict()
     task_group_sort = get_task_group_children_getter()
     mapped = is_mapped(task_group)
     children = [
-        task_group_to_dict_grid(x, 
parent_group_is_mapped=parent_group_is_mapped or mapped)
-        for x in task_group_sort(task_group)
+        task_group_to_dict_grid(
+            child,
+            group_dict=group_dict,
+            parent_group_is_mapped=parent_group_is_mapped or mapped,
+        )
+        for child in task_group_sort(task_group, group_dict)
     ]
 
     node = {
diff --git a/airflow-core/src/airflow/serialization/definitions/taskgroup.py 
b/airflow-core/src/airflow/serialization/definitions/taskgroup.py
index 65d59cb15f1..0e0ae06572d 100644
--- a/airflow-core/src/airflow/serialization/definitions/taskgroup.py
+++ b/airflow-core/src/airflow/serialization/definitions/taskgroup.py
@@ -27,6 +27,7 @@ from typing import TYPE_CHECKING
 import attrs
 import methodtools
 
+from airflow._shared.dagnode.node import TaskGroupMixin
 from airflow.serialization.definitions.node import DAGNode
 
 if TYPE_CHECKING:
@@ -38,7 +39,7 @@ if TYPE_CHECKING:
 
 
 @attrs.define(eq=False, hash=False, kw_only=True)
-class SerializedTaskGroup(DAGNode):
+class SerializedTaskGroup(TaskGroupMixin, DAGNode):
     """Serialized representation of a TaskGroup used in protected processes."""
 
     _group_id: str | None = attrs.field(alias="group_id")
@@ -215,7 +216,9 @@ class SerializedTaskGroup(DAGNode):
                 yield group
             group = group.parent_group
 
-    def topological_sort(self) -> list[DAGNode]:
+    def topological_sort(
+        self, *, group_dict: dict[str | None, SerializedTaskGroup] | None = 
None
+    ) -> list[DAGNode]:
         """
         Sort children topologically — a task always comes after its upstream 
dependencies.
 
@@ -231,11 +234,13 @@ class SerializedTaskGroup(DAGNode):
         nodes = list(children.values())
         n = len(nodes)
         id_to_idx = {nid: i for i, nid in enumerate(children)}
+        if group_dict is None:
+            group_dict = self.dag.task_group.get_task_group_dict()
 
         projected: list[tuple[int, ...]] = [()] * n
         nodes_with_back_edge = 0
         for i, child in enumerate(nodes):
-            deps = self._project_child_deps(i, child, id_to_idx)
+            deps = self._project_child_deps(i, child, id_to_idx, group_dict)
             if deps:
                 projected[i] = deps
                 if any(d > i for d in deps):
@@ -248,9 +253,13 @@ class SerializedTaskGroup(DAGNode):
         return self._sweep_projection(nodes, projected)
 
     def _project_child_deps(
-        self, child_idx: int, child: DAGNode, id_to_idx: dict[str, int]
+        self,
+        child_idx: int,
+        child: DAGNode,
+        id_to_idx: dict[str, int],
+        group_dict: dict[str | None, SerializedTaskGroup],
     ) -> tuple[int, ...]:
-        upstream_ids = child.upstream_task_ids
+        upstream_ids = child._topological_upstream_ids
         if not upstream_ids:
             return ()
         sib_deps: set[int] = set()
@@ -260,8 +269,10 @@ class SerializedTaskGroup(DAGNode):
                 if j != child_idx:
                     sib_deps.add(j)
                 continue
-            edge = self.dag.get_task(edge_id)
-            tg = edge.task_group
+            tg = group_dict.get(edge_id)
+            if tg is None:
+                edge = self.dag.get_task(edge_id)
+                tg = edge.task_group
             while tg is not None:
                 anc_idx = id_to_idx.get(tg.node_id)
                 if anc_idx is not None:
diff --git a/airflow-core/tests/unit/utils/test_task_group.py 
b/airflow-core/tests/unit/utils/test_task_group.py
index 2d1458e95fb..e328c42ce01 100644
--- a/airflow-core/tests/unit/utils/test_task_group.py
+++ b/airflow-core/tests/unit/utils/test_task_group.py
@@ -33,6 +33,7 @@ from airflow.sdk import (
     task_group as task_group_decorator,
     teardown,
 )
+from airflow.serialization.definitions.taskgroup import SerializedTaskGroup
 from airflow.utils.dag_edges import dag_edges
 
 from tests_common.test_utils.dag import create_scheduler_dag
@@ -243,6 +244,34 @@ def test_task_group_to_dict_alternative_syntax():
     assert task_group_to_dict(serialized_dag.task_group) == EXPECTED_JSON
 
 
+def test_task_group_to_dict_builds_group_dict_once(monkeypatch):
+    """Rendering the whole tree threads one group_dict; it is not rebuilt per 
nested group."""
+    with DAG("test_group_dict_once", schedule=None, start_date=DEFAULT_DATE) 
as dag:
+        with TaskGroup("outer"):
+            with TaskGroup("inner"):
+                EmptyOperator(task_id="a")
+                EmptyOperator(task_id="b")
+        with TaskGroup("sibling"):
+            EmptyOperator(task_id="c")
+
+    serialized = create_scheduler_dag(dag)
+
+    calls = 0
+    original = SerializedTaskGroup.get_task_group_dict
+
+    def counting(self):
+        nonlocal calls
+        calls += 1
+        return original(self)
+
+    monkeypatch.setattr(SerializedTaskGroup, "get_task_group_dict", counting)
+
+    # A full render must build the group map exactly once, not once per group.
+    task_group_to_dict(serialized.task_group)
+
+    assert calls == 1
+
+
 def test_task_group_to_dict_grid_includes_task_group_doc_md(dag_maker):
     logical_date = pendulum.parse("20200101")
     with dag_maker("test_task_group_to_dict_doc_md", schedule=None, 
start_date=logical_date) as dag:
@@ -493,17 +522,11 @@ def test_task_group_to_dict_and_dag_edges(dag_maker):
     nodes = task_group_to_dict(dag.task_group)
     edges = dag_edges(dag)
 
+    # group_d depends on group_c (`group_d << group_c`), so it must sort after 
group_c
+    # rather than before task1, which has no dependency on it at all.
     expected_node_id = {
         "id": None,
         "children": [
-            {
-                "id": "group_d",
-                "children": [
-                    {"id": "group_d.task11"},
-                    {"id": "group_d.task12"},
-                    {"id": "group_d.upstream_join_id"},
-                ],
-            },
             {"id": "task1"},
             {
                 "id": "group_a",
@@ -532,6 +555,14 @@ def test_task_group_to_dict_and_dag_edges(dag_maker):
                     {"id": "group_c.downstream_join_id"},
                 ],
             },
+            {
+                "id": "group_d",
+                "children": [
+                    {"id": "group_d.task11"},
+                    {"id": "group_d.task12"},
+                    {"id": "group_d.upstream_join_id"},
+                ],
+            },
             {"id": "task10"},
             {"id": "task9"},
         ],
@@ -719,12 +750,17 @@ def test_build_task_group_deco_context_manager(dag_maker):
     assert dag.task_dict["section_1.section_2.task_4"].downstream_task_ids == 
{"task_end"}
 
     # Node IDs test
+    # task_start feeds section_1.task_1 directly (a task-level dep crossing 
into the
+    # group), so section_1 must sort after task_start, not before it.
     node_ids = {
         "id": None,
         "children": [
+            {"id": "task_start"},
             {
                 "id": "section_1",
                 "children": [
+                    {"id": "section_1.task_1"},
+                    {"id": "section_1.task_2"},
                     {
                         "id": "section_1.section_2",
                         "children": [
@@ -732,12 +768,9 @@ def test_build_task_group_deco_context_manager(dag_maker):
                             {"id": "section_1.section_2.task_4"},
                         ],
                     },
-                    {"id": "section_1.task_1"},
-                    {"id": "section_1.task_2"},
                 ],
             },
             {"id": "task_end"},
-            {"id": "task_start"},
         ],
     }
 
@@ -1178,6 +1211,79 @@ def test_topological_sort_serialized_layered():
                 )
 
 
+def test_topological_group_dep_list_syntax():
+    """List-based deps (`[b0, b1] >> a`) must produce the same topological 
order as individual deps.
+
+    Declaring a group dependency via a list (`groups >> a`) only populates
+    `upstream_group_ids`, not `upstream_task_ids`, so `a` must not sort as if 
it had no
+    upstream at all.
+    """
+    with DAG("test_dag_list_dep", schedule=None, start_date=DEFAULT_DATE) as 
dag:
+        with TaskGroup("a") as tg_a:
+            EmptyOperator(task_id="task")
+
+        groups = []
+        for x in range(3):
+            with TaskGroup(f"b_{x}") as tg_b:
+                EmptyOperator(task_id="task")
+            groups.append(tg_b)
+
+        groups >> tg_a  # list-based dep — previously produced the wrong order
+
+    order = [node.node_id for node in dag.task_group.topological_sort()]
+    a_idx = order.index("a")
+    assert all(order.index(f"b_{x}") < a_idx for x in range(3)), (
+        f"Expected all b_x before a in topological order, got: {order!r}"
+    )
+
+
+def test_topological_sort_serialized_list_dep_between_groups():
+    """Same as test_topological_group_dep_list_syntax, exercised on the 
serialized variant."""
+    with DAG("test_dag_list_dep_serialized", schedule=None, 
start_date=DEFAULT_DATE) as dag:
+        with TaskGroup("a"):
+            EmptyOperator(task_id="task")
+
+        groups = []
+        for x in range(3):
+            with TaskGroup(f"b_{x}") as tg_b:
+                EmptyOperator(task_id="task")
+            groups.append(tg_b)
+
+        groups >> dag.task_group.children["a"]
+
+    serialized = create_scheduler_dag(dag)
+    order = [node.node_id for node in serialized.task_group.topological_sort()]
+    a_idx = order.index("a")
+    assert all(order.index(f"b_{x}") < a_idx for x in range(3)), (
+        f"Expected all b_x before a in topological order, got: {order!r}"
+    )
+
+
+def test_topological_sort_serialized_task_level_cross_group_dep():
+    """Task-level deps between groups are respected for ordering after 
serialization.
+
+    A task-level dependency that crosses into another group's entry task 
(bypassing any
+    group-to-group edge) must still order the downstream group after the 
upstream one.
+    """
+    with DAG("test_cross_group_task_dep", schedule=None, 
start_date=DEFAULT_DATE) as dag:
+        with TaskGroup("stage_b"):
+            b_start = EmptyOperator(task_id="b_start")
+            b_end = EmptyOperator(task_id="b_end")
+            b_start >> b_end
+
+        with TaskGroup("stage_a"):
+            a_start = EmptyOperator(task_id="a_start")
+            a_end = EmptyOperator(task_id="a_end")
+            a_start >> a_end
+
+        b_end >> a_start
+
+    serialized = create_scheduler_dag(dag)
+    order = [node.node_id for node in serialized.task_group.topological_sort()]
+
+    assert order.index("stage_b") < order.index("stage_a")
+
+
 def 
test_topological_sort_serialized_padded_reverse_chain_uses_pass_numbering(monkeypatch):
     dag = _make_padded_reverse_chain(chain_length=80, independent_count=80)
     serialized = create_scheduler_dag(dag)
diff --git a/shared/dagnode/src/airflow_shared/dagnode/node.py 
b/shared/dagnode/src/airflow_shared/dagnode/node.py
index 7d52ff1ea1f..651547a183b 100644
--- a/shared/dagnode/src/airflow_shared/dagnode/node.py
+++ b/shared/dagnode/src/airflow_shared/dagnode/node.py
@@ -140,6 +140,16 @@ class GenericDAGNode(Generic[Dag, Task, TaskGroup]):
             raise RuntimeError(f"Operator {self} has not been assigned to a 
Dag yet")
         return [self.dag.get_task(tid) for tid in self.downstream_task_ids]
 
+    @property
+    def _topological_upstream_ids(self) -> Collection[str]:
+        """
+        Node ids this node must be ordered after within its parent group.
+
+        A plain task depends only on its direct upstream tasks. Task groups 
override
+        this to also cover group-to-group and cross-group edges.
+        """
+        return self.upstream_task_ids
+
     def has_dag(self) -> bool:
         return self.dag is not None
 
@@ -263,3 +273,31 @@ class GenericDAGNode(Generic[Dag, Task, TaskGroup]):
         for task in self.get_upstreams_only_setups_and_teardowns():
             if task.is_setup:
                 yield task
+
+
+class TaskGroupMixin:
+    """Mixin to host common logic between authored and serialized task group 
classes."""
+
+    upstream_task_ids: set[str]
+    upstream_group_ids: set[str | None]
+
+    def get_roots(self) -> Iterable[GenericDAGNode]:
+        raise NotImplementedError()
+
+    @property
+    def _topological_upstream_ids(self) -> Collection[str]:
+        """
+        Node ids this node must be ordered after within its parent group.
+
+        A group's upstream_task_ids only reflects direct task-to-group edges
+        (e.g. ``task >> this_group``). This explicitly pulls in two more cases:
+
+        * Group-to-group edges (e.g. ``another_group >> this_group``).
+        * Task-level edges crossing into the group.
+        """
+        return self.upstream_task_ids.union(
+            (gid for gid in self.upstream_group_ids if gid is not None),
+            (t for root in self.get_roots() for t in root.upstream_task_ids),
+        )
+
+    # TODO: Move more duplicated logic between Core and SDK task group types.
diff --git a/task-sdk/src/airflow/sdk/definitions/taskgroup.py 
b/task-sdk/src/airflow/sdk/definitions/taskgroup.py
index 14bb2fba319..5799dc8e308 100644
--- a/task-sdk/src/airflow/sdk/definitions/taskgroup.py
+++ b/task-sdk/src/airflow/sdk/definitions/taskgroup.py
@@ -29,6 +29,7 @@ from typing import TYPE_CHECKING, Any
 import attrs
 
 from airflow.sdk import TriggerRule
+from airflow.sdk._shared.dagnode.node import TaskGroupMixin
 from airflow.sdk.definitions._internal.node import DAGNode, validate_group_key
 from airflow.sdk.exceptions import (
     AirflowDagCycleException,
@@ -92,7 +93,7 @@ def _convert_doc_md(doc_md: str | None) -> str | None:
 
 
 @attrs.define(repr=False)
-class TaskGroup(DAGNode):
+class TaskGroup(TaskGroupMixin, DAGNode):
     """
     A collection of tasks.
 
@@ -540,7 +541,7 @@ class TaskGroup(DAGNode):
             key=lambda node: (not isinstance(node, TaskGroup), node.node_id),
         )
 
-    def topological_sort(self) -> list[DAGNode]:
+    def topological_sort(self, *, group_dict: dict[str, TaskGroup] | None = 
None) -> list[DAGNode]:
         """
         Sort children topologically — a task always comes after its upstream 
dependencies.
 
@@ -561,11 +562,13 @@ class TaskGroup(DAGNode):
         nodes = list(children.values())
         n = len(nodes)
         id_to_idx = {nid: i for i, nid in enumerate(children)}
+        if group_dict is None:
+            group_dict = self.dag.task_group.get_task_group_dict()
 
         projected: list[tuple[int, ...]] = [()] * n
         nodes_with_back_edge = 0
         for i, child in enumerate(nodes):
-            deps = self._project_child_deps(i, child, id_to_idx)
+            deps = self._project_child_deps(i, child, id_to_idx, group_dict)
             if deps:
                 projected[i] = deps
                 if any(d > i for d in deps):
@@ -578,9 +581,13 @@ class TaskGroup(DAGNode):
         return self._sweep_projection(nodes, projected)
 
     def _project_child_deps(
-        self, child_idx: int, child: DAGNode, id_to_idx: dict[str, int]
+        self,
+        child_idx: int,
+        child: DAGNode,
+        id_to_idx: dict[str, int],
+        group_dict: dict[str, TaskGroup],
     ) -> tuple[int, ...]:
-        upstream_ids = child.upstream_task_ids
+        upstream_ids = child._topological_upstream_ids
         if not upstream_ids:
             return ()
         sib_deps: set[int] = set()
@@ -590,8 +597,10 @@ class TaskGroup(DAGNode):
                 if j != child_idx:
                     sib_deps.add(j)
                 continue
-            edge = self.dag.get_task(edge_id)
-            tg = edge.task_group
+            tg = group_dict.get(edge_id)
+            if tg is None:
+                edge = self.dag.get_task(edge_id)
+                tg = edge.task_group
             while tg is not None:
                 anc_idx = id_to_idx.get(tg.node_id)
                 if anc_idx is not None:
diff --git a/task-sdk/tests/task_sdk/definitions/test_taskgroup.py 
b/task-sdk/tests/task_sdk/definitions/test_taskgroup.py
index d11f8eb3632..ab338db50e8 100644
--- a/task-sdk/tests/task_sdk/definitions/test_taskgroup.py
+++ b/task-sdk/tests/task_sdk/definitions/test_taskgroup.py
@@ -1146,7 +1146,8 @@ def 
test_topological_sort_reverse_declared_order_matches_sweep():
     group = dag.task_group
     nodes = list(group.children.values())
     id_to_idx = {nid: i for i, nid in enumerate(group.children)}
-    projected = [group._project_child_deps(i, child, id_to_idx) for i, child 
in enumerate(nodes)]
+    group_dict = group.dag.task_group.get_task_group_dict()
+    projected = [group._project_child_deps(i, child, id_to_idx, group_dict) 
for i, child in enumerate(nodes)]
 
     sweep_order = [node.node_id for node in group._sweep_projection(nodes, 
projected)]
     pass_number_order = [node.node_id for node in 
group._sort_via_pass_numbering(nodes, projected)]

Reply via email to