This is an automated email from the ASF dual-hosted git repository.
dheerajturaga 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 ef9feefd83f Fix Grid and Graph 500 errors for cyclic TaskGroup
dependencies (#73724)
ef9feefd83f is described below
commit ef9feefd83fcd1cf7b18c245d424fd2eb5634211
Author: Dheeraj Turaga <[email protected]>
AuthorDate: Thu Oct 8 06:59:43 2026 -0500
Fix Grid and Graph 500 errors for cyclic TaskGroup dependencies (#73724)
---
.../airflow/serialization/definitions/taskgroup.py | 42 +------
.../api_fastapi/core_api/routes/ui/test_grid.py | 26 ++++
.../core_api/routes/ui/test_structure.py | 22 ++++
airflow-core/tests/unit/utils/test_task_group.py | 134 +++++++++++++++++++++
shared/dagnode/src/airflow_shared/dagnode/node.py | 113 ++++++++++++++++-
shared/dagnode/tests/dagnode/test_node.py | 36 +++++-
task-sdk/src/airflow/sdk/definitions/taskgroup.py | 45 +------
task-sdk/tests/task_sdk/definitions/test_dag.py | 12 ++
8 files changed, 353 insertions(+), 77 deletions(-)
diff --git a/airflow-core/src/airflow/serialization/definitions/taskgroup.py
b/airflow-core/src/airflow/serialization/definitions/taskgroup.py
index b82e1c0b933..c250363d837 100644
--- a/airflow-core/src/airflow/serialization/definitions/taskgroup.py
+++ b/airflow-core/src/airflow/serialization/definitions/taskgroup.py
@@ -236,10 +236,8 @@ class SerializedTaskGroup(TaskGroupMixin, DAGNode):
"""
Sort children topologically — a task always comes after its upstream
dependencies.
- See ``TaskGroup.topological_sort`` in task-sdk for the algorithm.
Cycles are
- treated as corrupt input: ``DAG.check_cycle`` rejects cyclic Dags
before
- serialization, so a cycle reaching this code indicates malformed
serialized data,
- and we raise ``ValueError`` rather than silently looping forever.
+ See ``TaskGroup.topological_sort`` in task-sdk for the algorithm,
including how siblings
+ that depend on each other in a cycle are ordered.
"""
children = self.children
if not children:
@@ -329,44 +327,16 @@ class SerializedTaskGroup(TaskGroupMixin, DAGNode):
emitted[i] = 1
order_append(nodes[i])
if len(next_pending) == len(pending):
- raise ValueError(f"A cyclic dependency occurred in dag:
{self.dag_id}")
+ return self._sort_cyclic_projection(nodes, projected)
pending = next_pending
return order
def _sort_via_pass_numbering(
self, nodes: list[DAGNode], projected: list[tuple[int, ...]]
) -> list[DAGNode]:
- n = len(nodes)
- in_degree = [len(deps) for deps in projected]
- successors: list[list[int]] = [[] for _ in range(n)]
- for i, deps in enumerate(projected):
- for d in deps:
- successors[d].append(i)
-
- pass_of = [0] * n
- queue: deque[int] = deque(i for i in range(n) if in_degree[i] == 0)
- processed = 0
- while queue:
- i = queue.popleft()
- my_pass = 1
- for d in projected[i]:
- d_pass = pass_of[d]
- if d < i:
- if d_pass > my_pass:
- my_pass = d_pass
- elif d_pass + 1 > my_pass:
- my_pass = d_pass + 1
- pass_of[i] = my_pass
- processed += 1
- for s in successors[i]:
- in_degree[s] -= 1
- if in_degree[s] == 0:
- queue.append(s)
-
- if processed != n:
- raise ValueError(f"A cyclic dependency occurred in dag:
{self.dag_id}")
-
- sorted_indices = sorted(range(n), key=lambda i: (pass_of[i], i))
+ sorted_indices = self._compute_pass_order(projected)
+ if len(sorted_indices) != len(nodes):
+ return self._sort_cyclic_projection(nodes, projected)
return [nodes[i] for i in sorted_indices]
def add(self, node: DAGNode) -> DAGNode:
diff --git
a/airflow-core/tests/unit/api_fastapi/core_api/routes/ui/test_grid.py
b/airflow-core/tests/unit/api_fastapi/core_api/routes/ui/test_grid.py
index ad4450903df..dabf34b9d84 100644
--- a/airflow-core/tests/unit/api_fastapi/core_api/routes/ui/test_grid.py
+++ b/airflow-core/tests/unit/api_fastapi/core_api/routes/ui/test_grid.py
@@ -1063,6 +1063,32 @@ class TestGetGridDataEndpoint:
{"id": "task_b", "label": "task_b"},
]
+ def test_structure_filter_creating_task_group_cycle(self, dag_maker,
test_client, session):
+ """Dropping ``g.guard`` makes ``g.second`` a root of ``g``, so ``g``
and ``bridge`` depend on each other."""
+ with dag_maker(dag_id="filtered_task_group_cycle", serialized=True,
session=session):
+ with TaskGroup(group_id="g"):
+ first = EmptyOperator(task_id="first")
+ second = EmptyOperator(task_id="second")
+ guard = EmptyOperator(task_id="guard")
+ guard >> second
+ bridge = EmptyOperator(task_id="bridge")
+ first >> bridge >> second
+ dag_maker.sync_dagbag_to_db()
+
+ response = test_client.get(
+
"/grid/structure/filtered_task_group_cycle?root=g.first&include_downstream=true"
+ )
+
+ assert response.status_code == 200
+ assert response.json() == [
+ {"id": "bridge", "label": "bridge"},
+ {
+ "id": "g",
+ "label": "g",
+ "children": [{"id": "g.first", "label": "first"}, {"id":
"g.second", "label": "second"}],
+ },
+ ]
+
# Tests for root, include_upstream, and include_downstream parameters
@pytest.mark.parametrize(
("params", "expected_task_ids", "description"),
diff --git
a/airflow-core/tests/unit/api_fastapi/core_api/routes/ui/test_structure.py
b/airflow-core/tests/unit/api_fastapi/core_api/routes/ui/test_structure.py
index 61b2eed0b3d..8010203dd69 100644
--- a/airflow-core/tests/unit/api_fastapi/core_api/routes/ui/test_structure.py
+++ b/airflow-core/tests/unit/api_fastapi/core_api/routes/ui/test_structure.py
@@ -393,6 +393,28 @@ class TestStructureDataEndpoint:
assert mapped_in_group["is_mapped"] is True
assert mapped_in_group["operator"] == "PythonOperator"
+ def test_filter_creating_task_group_cycle(self, dag_maker, test_client,
session):
+ """Dropping ``g.guard`` makes ``g.second`` a root of ``g``, so ``g``
and ``bridge`` depend on each other."""
+ with dag_maker(dag_id="filtered_task_group_cycle", serialized=True,
session=session):
+ with TaskGroup(group_id="g"):
+ first = EmptyOperator(task_id="first")
+ second = EmptyOperator(task_id="second")
+ guard = EmptyOperator(task_id="guard")
+ guard >> second
+ bridge = EmptyOperator(task_id="bridge")
+ first >> bridge >> second
+ dag_maker.sync_dagbag_to_db()
+
+ response = test_client.get(
+ "/structure/structure_data",
+ params={"dag_id": "filtered_task_group_cycle", "root": "g.first",
"include_downstream": True},
+ )
+
+ assert response.status_code == 200
+ nodes = response.json()["nodes"]
+ assert [node["id"] for node in nodes] == ["bridge", "g"]
+ assert [child["id"] for child in nodes[1]["children"]] == ["g.first",
"g.second"]
+
def test_ui_colors_passed_through_to_graph(self, dag_maker, test_client,
session):
"""Both raw hex colors and Chakra palette tokens reach the graph
unchanged, for operators and groups."""
diff --git a/airflow-core/tests/unit/utils/test_task_group.py
b/airflow-core/tests/unit/utils/test_task_group.py
index dc9a306ccdc..62f15d9a223 100644
--- a/airflow-core/tests/unit/utils/test_task_group.py
+++ b/airflow-core/tests/unit/utils/test_task_group.py
@@ -1351,6 +1351,140 @@ def
test_topological_sort_serialized_padded_reverse_chain_uses_pass_numbering(mo
assert position[f"r{i}"] < position[f"r{i + 1}"]
+# Deserialization orders a group's children by label, so these Dags declare
children in label order
+# to give the Task SDK sort the same input as the serialized one.
+
+
+def _make_sibling_groups_cycle():
+ with DAG("sibling_groups_cycle", schedule=None, start_date=DEFAULT_DATE)
as dag:
+ end = EmptyOperator(task_id="end")
+ with TaskGroup("group1"):
+ a1 = EmptyOperator(task_id="a1")
+ a2 = EmptyOperator(task_id="a2")
+ with TaskGroup("group2"):
+ b1 = EmptyOperator(task_id="b1")
+ b2 = EmptyOperator(task_id="b2")
+ start = EmptyOperator(task_id="start")
+ start >> [a1, b2]
+ a1 >> b1
+ b2 >> a2
+ [a2, b1] >> end
+ return dag
+
+
+def _make_group_bridged_by_outside_task():
+ with DAG("group_bridged_by_outside_task", schedule=None,
start_date=DEFAULT_DATE) as dag:
+ bridge = EmptyOperator(task_id="bridge")
+ with TaskGroup("group"):
+ a = EmptyOperator(task_id="a")
+ b = EmptyOperator(task_id="b")
+ a >> bridge >> b
+ return dag
+
+
+def _make_bridged_group_among_siblings():
+ with DAG("bridged_group_among_siblings", schedule=None,
start_date=DEFAULT_DATE) as dag:
+ after = EmptyOperator(task_id="after")
+ bridge = EmptyOperator(task_id="bridge")
+ with TaskGroup("group"):
+ a = EmptyOperator(task_id="a")
+ b = EmptyOperator(task_id="b")
+ for i in range(3):
+ EmptyOperator(task_id=f"x{i}")
+ a >> bridge >> b >> after
+ return dag
+
+
+def _make_nested_bridged_group():
+ with DAG("nested_bridged_group", schedule=None, start_date=DEFAULT_DATE)
as dag:
+ with TaskGroup("outer"):
+ after = EmptyOperator(task_id="after")
+ bridge = EmptyOperator(task_id="bridge")
+ with TaskGroup("inner"):
+ a = EmptyOperator(task_id="a")
+ b = EmptyOperator(task_id="b")
+ a >> bridge >> b >> after
+ return dag
+
+
+def _make_three_group_ring():
+ with DAG("three_group_ring", schedule=None, start_date=DEFAULT_DATE) as
dag:
+ groups = {}
+ for group_id in ("g0", "g1", "g2"):
+ with TaskGroup(group_id):
+ groups[group_id] = (EmptyOperator(task_id="first"),
EmptyOperator(task_id="second"))
+ groups["g1"][0] >> groups["g0"][1]
+ groups["g2"][0] >> groups["g1"][1]
+ groups["g0"][0] >> groups["g2"][1]
+ return dag
+
+
+def _get_topological_orders(group_dict):
+ return {
+ group_id: [node.node_id for node in
group.topological_sort(group_dict=group_dict)]
+ for group_id, group in group_dict.items()
+ }
+
+
[email protected](
+ ("make_dag", "expected"),
+ [
+ pytest.param(
+ _make_sibling_groups_cycle,
+ {
+ None: ["start", "group1", "group2", "end"],
+ "group1": ["group1.a1", "group1.a2"],
+ "group2": ["group2.b1", "group2.b2"],
+ },
+ id="sibling-groups",
+ ),
+ pytest.param(
+ _make_group_bridged_by_outside_task,
+ {None: ["bridge", "group"], "group": ["group.a", "group.b"]},
+ id="group-bridged-by-outside-task",
+ ),
+ # Only two of six siblings depend on a later sibling, which keeps the
sweep.
+ pytest.param(
+ _make_bridged_group_among_siblings,
+ {
+ None: ["bridge", "group", "x0", "x1", "x2", "after"],
+ "group": ["group.a", "group.b"],
+ },
+ id="bridged-group-among-siblings",
+ ),
+ # Two of outer's three children depend on a later sibling, which
selects pass numbering.
+ pytest.param(
+ _make_nested_bridged_group,
+ {
+ None: ["outer"],
+ "outer": ["outer.bridge", "outer.inner", "outer.after"],
+ "outer.inner": ["outer.inner.a", "outer.inner.b"],
+ },
+ id="nested-bridged-group",
+ ),
+ # Two of the three siblings depend on a later sibling, which selects
pass numbering.
+ pytest.param(
+ _make_three_group_ring,
+ {
+ None: ["g0", "g1", "g2"],
+ "g0": ["g0.first", "g0.second"],
+ "g1": ["g1.first", "g1.second"],
+ "g2": ["g2.first", "g2.second"],
+ },
+ id="three-group-ring",
+ ),
+ ],
+)
+def test_topological_sort_task_group_cycle(make_dag, expected):
+ """Siblings that depend on each other only at the group level are ordered
instead of raising."""
+ dag = make_dag()
+ dag.check_cycle()
+ serialized = create_scheduler_dag(dag)
+
+ assert
_get_topological_orders(serialized.task_group.get_task_group_dict()) == expected
+ assert _get_topological_orders(dag.task_group.get_task_group_dict()) ==
expected
+
+
def test_task_group_arrow_with_setup_group():
with DAG(dag_id="setup_group_teardown_group") as dag:
with TaskGroup("group_1") as g1:
diff --git a/shared/dagnode/src/airflow_shared/dagnode/node.py
b/shared/dagnode/src/airflow_shared/dagnode/node.py
index 9bc3b91f60a..15f060d25a5 100644
--- a/shared/dagnode/src/airflow_shared/dagnode/node.py
+++ b/shared/dagnode/src/airflow_shared/dagnode/node.py
@@ -17,12 +17,13 @@
from __future__ import annotations
+from collections import deque
from typing import TYPE_CHECKING, Any, Generic, Protocol, TypeVar
import structlog
if TYPE_CHECKING:
- from collections.abc import Collection, Iterable
+ from collections.abc import Collection, Iterable, Iterator
from typing import Self
from ..logging.types import Logger
@@ -59,6 +60,7 @@ class TaskGroupProtocol(Protocol):
Dag = TypeVar("Dag", bound=DagProtocol)
Task = TypeVar("Task", bound=TaskProtocol)
TaskGroup = TypeVar("TaskGroup", bound=TaskGroupProtocol)
+Node = TypeVar("Node")
class GenericDAGNode(Generic[Dag, Task, TaskGroup]):
@@ -294,4 +296,113 @@ class TaskGroupMixin:
(t for root in self.get_roots() for t in root.upstream_task_ids),
)
+ @staticmethod
+ def _compute_pass_order(projected: list[tuple[int, ...]]) -> list[int]:
+ """
+ Order child indices by (pass number, insertion index).
+
+ pass(X) is the earliest pass at which a greedy sweep in insertion
order would emit X:
+ ``max over deps d of (pass(d) if idx(d) < idx(X) else pass(d) + 1)``.
A dep declared
+ before X can be emitted in the same pass; a dep declared after X
forces X into the next
+ pass. Computed via Kahn's traversal in O(V + E).
+
+ :param projected: for each child, the indices of the siblings it
depends on
+ :return: the ordered indices, leaving out every child on or downstream
of a cycle
+ """
+ n = len(projected)
+ in_degree = [len(deps) for deps in projected]
+ successors: list[list[int]] = [[] for _ in range(n)]
+ for i, deps in enumerate(projected):
+ for d in deps:
+ successors[d].append(i)
+
+ pass_of = [0] * n
+ queue: deque[int] = deque(i for i in range(n) if in_degree[i] == 0)
+ processed: list[int] = []
+ while queue:
+ i = queue.popleft()
+ my_pass = 1
+ for d in projected[i]:
+ d_pass = pass_of[d]
+ if d < i:
+ if d_pass > my_pass:
+ my_pass = d_pass
+ elif d_pass + 1 > my_pass:
+ my_pass = d_pass + 1
+ pass_of[i] = my_pass
+ processed.append(i)
+ for s in successors[i]:
+ in_degree[s] -= 1
+ if in_degree[s] == 0:
+ queue.append(s)
+
+ return sorted(processed, key=lambda i: (pass_of[i], i))
+
+ def _sort_cyclic_projection(self, nodes: list[Node], projected:
list[tuple[int, ...]]) -> list[Node]:
+ """
+ Order children whose projection has a cycle, ordering the siblings on
each cycle as one unit.
+
+ ``DAG.check_cycle`` rejects task-level cycles, but a task with no
upstream inside its own
+ group counts as a root of that group, so edges routed through tasks
outside the group can
+ still make siblings depend on each other. ``partial_subset`` can also
create such a cycle
+ by dropping a task's in-group upstream. Grid and Graph must still
render these Dags.
+
+ Each unit is placed by its first child and follows the same pass
ordering as the acyclic
+ path; a unit's children keep insertion order.
+ """
+ component_of = self._find_projection_components(projected)
+ count = max(component_of) + 1
+ members: list[list[int]] = [[] for _ in range(count)]
+ component_deps: list[set[int]] = [set() for _ in range(count)]
+ for i, deps in enumerate(projected):
+ c = component_of[i]
+ members[c].append(i)
+ component_deps[c].update(component_of[d] for d in deps if
component_of[d] != c)
+ component_order = self._compute_pass_order([tuple(deps) for deps in
component_deps])
+ return [nodes[i] for c in component_order for i in members[c]]
+
+ @staticmethod
+ def _find_projection_components(projected: list[tuple[int, ...]]) ->
list[int]:
+ """Return each child's strongly connected component, numbered in order
of its first child."""
+ n = len(projected)
+ successors: list[list[int]] = [[] for _ in range(n)]
+ for i, deps in enumerate(projected):
+ for d in deps:
+ successors[d].append(i)
+
+ # Kosaraju: finish order along successors, then collect components
along dependencies.
+ visited = bytearray(n)
+ finish_order: list[int] = []
+ for start in range(n):
+ if visited[start]:
+ continue
+ visited[start] = 1
+ stack: list[tuple[int, Iterator[int]]] = [(start,
iter(successors[start]))]
+ while stack:
+ node, remaining = stack[-1]
+ for s in remaining:
+ if not visited[s]:
+ visited[s] = 1
+ stack.append((s, iter(successors[s])))
+ break
+ else:
+ stack.pop()
+ finish_order.append(node)
+
+ root_of = [-1] * n
+ for start in reversed(finish_order):
+ if root_of[start] != -1:
+ continue
+ root_of[start] = start
+ to_visit = [start]
+ while to_visit:
+ node = to_visit.pop()
+ for d in projected[node]:
+ if root_of[d] == -1:
+ root_of[d] = start
+ to_visit.append(d)
+
+ numbering: dict[int, int] = {}
+ return [numbering.setdefault(root, len(numbering)) for root in root_of]
+
# TODO: Move more duplicated logic between Core and SDK task group types.
diff --git a/shared/dagnode/tests/dagnode/test_node.py
b/shared/dagnode/tests/dagnode/test_node.py
index 2166074d5db..6855b861730 100644
--- a/shared/dagnode/tests/dagnode/test_node.py
+++ b/shared/dagnode/tests/dagnode/test_node.py
@@ -22,7 +22,7 @@ from unittest import mock
import pytest
-from airflow_shared.dagnode.node import GenericDAGNode
+from airflow_shared.dagnode.node import GenericDAGNode, TaskGroupMixin
class Task:
@@ -81,3 +81,37 @@ class TestDAGNode:
assert node.label == "test_group_id.test_node_id"
node.task_group = TaskGroup(prefix_group_id)
assert node.label == expected_label
+
+
+class TestTaskGroupMixin:
+ @pytest.mark.parametrize(
+ ("projected", "expected"),
+ [
+ pytest.param([(), (0,), (1,)], [0, 1, 2], id="chain"),
+ pytest.param([(1,), (2,), (0,)], [0, 0, 0], id="ring"),
+ pytest.param([(), (2,), (1,), (1,)], [0, 1, 1, 2],
id="cycle-between-acyclic-children"),
+ pytest.param([(1,), (0,), (3,), (2,)], [0, 0, 1, 1],
id="two-cycles"),
+ ],
+ )
+ def test_find_projection_components(self, projected, expected):
+ assert TaskGroupMixin._find_projection_components(projected) ==
expected
+
+ @pytest.mark.parametrize(
+ ("nodes", "projected", "expected"),
+ [
+ pytest.param(
+ ["after", "bridge", "group", "x0"],
+ [(2,), (2,), (1,), ()],
+ ["bridge", "group", "x0", "after"],
+ id="cycle-before-its-downstream",
+ ),
+ pytest.param(
+ ["end", "group1", "group2", "start"],
+ [(1, 2), (2, 3), (1, 3), ()],
+ ["start", "group1", "group2", "end"],
+ id="cycle-after-its-upstream",
+ ),
+ ],
+ )
+ def test_sort_cyclic_projection(self, nodes, projected, expected):
+ assert TaskGroupMixin()._sort_cyclic_projection(nodes, projected) ==
expected
diff --git a/task-sdk/src/airflow/sdk/definitions/taskgroup.py
b/task-sdk/src/airflow/sdk/definitions/taskgroup.py
index 89bc37d7127..21417e75b01 100644
--- a/task-sdk/src/airflow/sdk/definitions/taskgroup.py
+++ b/task-sdk/src/airflow/sdk/definitions/taskgroup.py
@@ -32,7 +32,6 @@ 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,
DuplicateTaskIdFound,
NodeNotFound,
TaskAlreadyInTaskGroup,
@@ -561,7 +560,8 @@ class TaskGroup(TaskGroupMixin, DAGNode):
traversal, O((V + E) log V), avoids the O(N²) blowup the sweep would
hit.
Both branches produce the same emission order: level-by-legacy-pass,
ties broken by
- children insertion order.
+ children insertion order. Siblings that depend on each other in a
cycle, which
+ ``DAG.check_cycle`` allows, are ordered as one unit (see
``_sort_cyclic_projection``).
"""
children = self.children
if not children:
@@ -657,49 +657,16 @@ class TaskGroup(TaskGroupMixin, DAGNode):
emitted[i] = 1
order_append(nodes[i])
if len(next_pending) == len(pending):
- raise AirflowDagCycleException(f"A cyclic dependency occurred
in dag: {self.dag_id}")
+ return self._sort_cyclic_projection(nodes, projected)
pending = next_pending
return order
def _sort_via_pass_numbering(
self, nodes: list[DAGNode], projected: list[tuple[int, ...]]
) -> list[DAGNode]:
- # Sort by (pass_number, insertion_index). pass_number(X) is the
earliest pass at
- # which a greedy-sweep emission of X would occur:
- # pass(X) = max over deps d of (pass(d) if idx(d) < idx(X) else
pass(d)+1)
- # A dep declared before X can be emitted in the same pass; a dep
declared after X
- # forces X into the next pass. Computed via Kahn's traversal in O(V +
E).
- n = len(nodes)
- in_degree = [len(deps) for deps in projected]
- successors: list[list[int]] = [[] for _ in range(n)]
- for i, deps in enumerate(projected):
- for d in deps:
- successors[d].append(i)
-
- pass_of = [0] * n
- queue: deque[int] = deque(i for i in range(n) if in_degree[i] == 0)
- processed = 0
- while queue:
- i = queue.popleft()
- my_pass = 1
- for d in projected[i]:
- d_pass = pass_of[d]
- if d < i:
- if d_pass > my_pass:
- my_pass = d_pass
- elif d_pass + 1 > my_pass:
- my_pass = d_pass + 1
- pass_of[i] = my_pass
- processed += 1
- for s in successors[i]:
- in_degree[s] -= 1
- if in_degree[s] == 0:
- queue.append(s)
-
- if processed != n:
- raise AirflowDagCycleException(f"A cyclic dependency occurred in
dag: {self.dag_id}")
-
- sorted_indices = sorted(range(n), key=lambda i: (pass_of[i], i))
+ sorted_indices = self._compute_pass_order(projected)
+ if len(sorted_indices) != len(nodes):
+ return self._sort_cyclic_projection(nodes, projected)
return [nodes[i] for i in sorted_indices]
def iter_mapped_task_groups(self) -> Iterator[MappedTaskGroup]:
diff --git a/task-sdk/tests/task_sdk/definitions/test_dag.py
b/task-sdk/tests/task_sdk/definitions/test_dag.py
index 8de4f0b80f0..604c212d5da 100644
--- a/task-sdk/tests/task_sdk/definitions/test_dag.py
+++ b/task-sdk/tests/task_sdk/definitions/test_dag.py
@@ -80,6 +80,18 @@ class TestDag:
assert dag.topological_sort() == ()
+ def test_dag_topological_sort_task_group_cycle(self):
+ """A TaskGroup that depends on a sibling in a cycle, although
check_cycle passes, still sorts."""
+ with DAG("dag", schedule=None, start_date=DEFAULT_DATE) as dag:
+ bridge = BaseOperator(task_id="bridge")
+ with TaskGroup("group"):
+ a = BaseOperator(task_id="a")
+ b = BaseOperator(task_id="b")
+ a >> bridge >> b
+ dag.check_cycle()
+
+ assert [task.task_id for task in dag.topological_sort()] == ["bridge",
"group.a", "group.b"]
+
def test_dag_naive_start_date_string(self):
DAG("DAG", schedule=None, default_args={"start_date": "2019-06-01"})