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 4ca1f6693bb Deprecate cyclic TaskGroup dependencies (#73746)
4ca1f6693bb is described below
commit 4ca1f6693bb380ae1f148a6187001adbcadbfa22
Author: Dheeraj Turaga <[email protected]>
AuthorDate: Sun Oct 11 08:00:35 2026 -0500
Deprecate cyclic TaskGroup dependencies (#73746)
---
airflow-core/docs/core-concepts/dags.rst | 72 +++++++
airflow-core/newsfragments/73746.significant.rst | 22 ++
.../core_api/openapi/v2-rest-api-generated.yaml | 1 +
.../src/airflow/dag_processing/collection.py | 1 +
airflow-core/src/airflow/dag_processing/dagbag.py | 31 ++-
.../src/airflow/dag_processing/processor.py | 3 +
airflow-core/src/airflow/models/dagwarning.py | 1 +
.../airflow/ui/openapi-gen/requests/schemas.gen.ts | 2 +-
.../airflow/ui/openapi-gen/requests/types.gen.ts | 2 +-
.../core_api/routes/public/test_dag_warning.py | 6 +-
.../tests/unit/dag_processing/test_collection.py | 24 +++
.../tests/unit/dag_processing/test_dagbag.py | 58 +++++-
.../tests/unit/dag_processing/test_processor.py | 35 ++++
.../src/airflowctl/api/datamodels/generated.py | 1 +
task-sdk/src/airflow/sdk/definitions/dag.py | 45 ++++
task-sdk/src/airflow/sdk/definitions/taskgroup.py | 45 +++-
task-sdk/src/airflow/sdk/exceptions.py | 4 +
task-sdk/tests/task_sdk/definitions/test_dag.py | 228 ++++++++++++++++++++-
18 files changed, 563 insertions(+), 18 deletions(-)
diff --git a/airflow-core/docs/core-concepts/dags.rst
b/airflow-core/docs/core-concepts/dags.rst
index 724619aae9f..9946e17bb72 100644
--- a/airflow-core/docs/core-concepts/dags.rst
+++ b/airflow-core/docs/core-concepts/dags.rst
@@ -624,6 +624,78 @@ If you want to see a more advanced use of TaskGroup, you
can look at the ``examp
When using the ``@task_group`` decorator, the decorated-function's
docstring will be used as the TaskGroups tooltip in the UI except when a
``tooltip`` value is explicitly supplied.
+.. _concepts:taskgroup-cycles:
+
+Cyclic TaskGroup dependencies
+^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
+
+.. deprecated:: 3.4.0
+ Dags with cyclic TaskGroup dependencies are planned to fail Dag parsing
from Airflow 3.5.
+
+When each TaskGroup is treated as a single unit, a TaskGroup and its siblings
must not depend on each other
+in a cycle. A dependency into or out of any task in a group counts as a
dependency of the whole group. This
+can make a group both upstream and downstream of a sibling, although no
task-level dependency forms a cycle:
+
+.. code-block:: python
+
+ 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")
+
+ a1 >> b1 # group2 depends on group1
+ b2 >> a2 # group1 depends on group2
+
+A path that leaves a TaskGroup and comes back into it also forms a cycle, even
when the tasks inside the
+group are ordered directly as well:
+
+.. code-block:: python
+
+ with TaskGroup("group"):
+ a = EmptyOperator(task_id="a")
+ b = EmptyOperator(task_id="b")
+ a >> b
+
+ bridge = EmptyOperator(task_id="bridge")
+
+ a >> bridge >> b # group -> bridge -> group
+
+This includes a setup and teardown pair inside a TaskGroup with the work they
wrap outside it. This is
+intended: a TaskGroup that holds a setup and its teardown must also hold the
tasks between them.
+
+.. code-block:: python
+
+ with TaskGroup("cluster"):
+ create = EmptyOperator(task_id="create")
+ delete = EmptyOperator(task_id="delete")
+
+ work = EmptyOperator(task_id="work")
+
+ create >> work >> delete.as_teardown(setups=create) # cluster -> work ->
cluster
+
+These Dags still parse and run, but features that act on a TaskGroup as a
whole need an unambiguous order
+between groups. Parsing them emits a ``TaskGroupCycleDeprecationWarning`` and
a Dag warning in the UI that
+name the TaskGroups and tasks involved. The warning lists at most five members
of each cycle and at most ten
+cycles, with a count of the ones left out.
+
+To remove the cycle, move tasks between TaskGroups, or out of them, so that
each group depends on the others
+in one direction only. In the second example, move ``bridge`` into ``group``,
or move ``b`` out of it. In the
+setup and teardown example, move ``work`` into ``cluster``.
+
+Only Dags authored with the Python Task SDK are checked. Dags from the Go,
Java and TypeScript SDKs are not
+checked yet.
+
+To catch these Dags in CI, turn the warning into an error. The check runs when
a Dag is added to a
+``DagBag``, so this works for a pytest test that loads your Dags into a
``DagBag`` and asserts there are no
+import errors, but not for one that only imports a Dag file:
+
+.. code-block:: bash
+
+ pytest -W error::airflow.sdk.exceptions.TaskGroupCycleDeprecationWarning
tests/test_dags.py
+
.. _concepts:edge-labels:
Edge Labels
diff --git a/airflow-core/newsfragments/73746.significant.rst
b/airflow-core/newsfragments/73746.significant.rst
new file mode 100644
index 00000000000..6d976dfc2b5
--- /dev/null
+++ b/airflow-core/newsfragments/73746.significant.rst
@@ -0,0 +1,22 @@
+Deprecate cyclic TaskGroup dependencies
+
+A Dag whose TaskGroups depend on each other in a cycle, when each TaskGroup is
treated as a single unit,
+now issues a ``TaskGroupCycleDeprecationWarning`` at parse time and shows a
Dag warning in the UI that
+names the TaskGroups and tasks involved. These Dags still parse and run,
because their task dependencies
+are acyclic, but Airflow 3.5 is planned to reject them at parse time.
+
+A dependency into or out of any task in a TaskGroup counts as a dependency of
the whole group, so a path
+that leaves a TaskGroup and comes back into it is a cycle. For example, ``a >>
bridge >> b``, with ``a``
+and ``b`` in the same TaskGroup and ``bridge`` outside it, makes the group
depend on ``bridge`` and
+``bridge`` depend on the group, even if ``a >> b`` is also set.
+
+**Migration:**
+
+- Move tasks between TaskGroups, or out of them, so that each group depends on
the others in one direction
+ only. In the example above, move ``bridge`` into the group, or move ``b``
out of it.
+- To catch these Dags in CI, run your Dag tests with
+ ``-W error::airflow.sdk.exceptions.TaskGroupCycleDeprecationWarning``. The
check runs when a Dag is
+ added to a ``DagBag``, so only tests that load your Dags into a ``DagBag``
and assert there are no import
+ errors catch them; a test that only imports a Dag file does not.
+
+See "Cyclic TaskGroup dependencies" in the Dags documentation.
diff --git
a/airflow-core/src/airflow/api_fastapi/core_api/openapi/v2-rest-api-generated.yaml
b/airflow-core/src/airflow/api_fastapi/core_api/openapi/v2-rest-api-generated.yaml
index 9ca0cf9db1e..28b9241bc06 100644
---
a/airflow-core/src/airflow/api_fastapi/core_api/openapi/v2-rest-api-generated.yaml
+++
b/airflow-core/src/airflow/api_fastapi/core_api/openapi/v2-rest-api-generated.yaml
@@ -16250,6 +16250,7 @@ components:
- duplicate dag id
- non-existent pool
- runtime varying value
+ - task group cycle
title: DagWarningType
description: 'Enum for DAG warning types.
diff --git a/airflow-core/src/airflow/dag_processing/collection.py
b/airflow-core/src/airflow/dag_processing/collection.py
index 3e20722c6c2..cd7228d210f 100644
--- a/airflow-core/src/airflow/dag_processing/collection.py
+++ b/airflow-core/src/airflow/dag_processing/collection.py
@@ -587,6 +587,7 @@ def update_dag_parsing_results_in_db(
DagWarningType.DUPLICATE_DAG_ID,
DagWarningType.NONEXISTENT_POOL,
DagWarningType.RUNTIME_VARYING_VALUE,
+ DagWarningType.TASK_GROUP_CYCLE,
),
files_parsed: set[tuple[str, str]] | None = None,
dag_source_codes: dict[str, DagSourceCode] | None = None,
diff --git a/airflow-core/src/airflow/dag_processing/dagbag.py
b/airflow-core/src/airflow/dag_processing/dagbag.py
index 3b68817758d..dc80433676c 100644
--- a/airflow-core/src/airflow/dag_processing/dagbag.py
+++ b/airflow-core/src/airflow/dag_processing/dagbag.py
@@ -46,6 +46,7 @@ from airflow.exceptions import (
from airflow.executors.executor_loader import ExecutorLoader
from airflow.listeners.listener import get_listener_manager
from airflow.models.pool import Pool
+from airflow.sdk.exceptions import AirflowDagCycleException,
TaskGroupCycleDeprecationWarning
from airflow.sdk.importers import DagImportError, get_importer_registry
from airflow.serialization.definitions.dag import SerializedDAG
from airflow.serialization.definitions.notset import NOTSET, ArgNotSet,
is_arg_set
@@ -213,6 +214,7 @@ class DagBag(LoggingMixin):
self.import_errors: dict[str, str] = {}
self.captured_warnings: dict[str, tuple[str, ...]] = {}
self._import_warnings: dict[str, list[DagImportWarning]] = {}
+ self.task_group_cycle_warnings: dict[str, str] = {}
# The source code of each definition that produced Dags, keyed by its
fileloc
self.dag_source_codes: dict[str, DagSourceCode] = {}
# Only used by SchedulerJob to compare the dag_hash to identify change
in DAGs
@@ -446,7 +448,11 @@ class DagBag(LoggingMixin):
"""Get the set of DagWarnings for the bagged dags."""
from airflow.models.dagwarning import DagWarning, DagWarningType
- dag_warnings: set[DagWarning] = set()
+ dag_warnings: set[DagWarning] = {
+ DagWarning(dag_id, DagWarningType.TASK_GROUP_CYCLE, message)
+ for dag_id, message in self.task_group_cycle_warnings.items()
+ if dag_id in self.dags
+ }
for dag in self.dags.values():
for import_warning in self._import_warnings.get(dag.fileloc, ()):
# Only importer-namespaced types (``yaml:deprecated_field``)
are Dag warnings;
@@ -491,7 +497,21 @@ class DagBag(LoggingMixin):
:raises: AirflowDagCycleException if a cycle is detected.
:raises: AirflowDagDuplicatedIdException if this dag already exists in
the bag.
"""
- dag.check_cycle()
+ task_group_cycle_warning = None
+ with warnings.catch_warnings(record=True) as captured_warnings:
+ # DeprecationWarning is ignored by default outside __main__, which
would hide it here too.
+ warnings.simplefilter("always", TaskGroupCycleDeprecationWarning)
+ dag.check_cycle()
+ for captured in captured_warnings:
+ if issubclass(captured.category, TaskGroupCycleDeprecationWarning):
+ task_group_cycle_warning = str(captured.message)
+ warnings.warn_explicit(
+ message=captured.message,
+ category=captured.category,
+ filename=captured.filename,
+ lineno=captured.lineno,
+ source=captured.source,
+ )
dag.resolve_template_files()
dag.last_loaded = timezone.utcnow()
@@ -515,6 +535,11 @@ class DagBag(LoggingMixin):
self.log.exception(e)
raise AirflowClusterPolicyError(e)
self._add_to_bag(dag)
+ # Only once bagged: a rejected duplicate must not touch the warning of
the Dag it lost to.
+ if task_group_cycle_warning is None:
+ self.task_group_cycle_warnings.pop(dag.dag_id, None)
+ else:
+ self.task_group_cycle_warnings[dag.dag_id] =
task_group_cycle_warning
def _add_to_bag(self, dag: BaggedDAG) -> None:
"""
@@ -522,8 +547,6 @@ class DagBag(LoggingMixin):
:raises: AirflowDagDuplicatedIdException if this dag already exists in
the bag.
"""
- from airflow.sdk.exceptions import AirflowDagCycleException
-
try:
prev_dag = self.dags.get(dag.dag_id)
if prev_dag and prev_dag.fileloc != dag.fileloc:
diff --git a/airflow-core/src/airflow/dag_processing/processor.py
b/airflow-core/src/airflow/dag_processing/processor.py
index c3feb3f4959..e6f3f422a0a 100644
--- a/airflow-core/src/airflow/dag_processing/processor.py
+++ b/airflow-core/src/airflow/dag_processing/processor.py
@@ -254,6 +254,8 @@ def _parse_file(msg: DagFileParseRequest, log:
FilteringBoundLogger) -> DagFileP
serialized_dags, serialization_import_errors = _serialize_dags(bag, log)
bag.import_errors.update(serialization_import_errors)
+ # Dag warnings reference the dag table, so only Dags that serialized can
carry one.
+ serialized_dag_ids = {dag.dag_id for dag in serialized_dags}
result = DagFileParsingResult(
fileloc=msg.file,
serialized_dags=serialized_dags,
@@ -263,6 +265,7 @@ def _parse_file(msg: DagFileParseRequest, log:
FilteringBoundLogger) -> DagFileP
*(
{"dag_id": w.dag_id, "warning_type": w.warning_type,
"message": w.message}
for w in bag.dag_warnings
+ if w.dag_id in serialized_dag_ids
),
],
parsed_definitions=bag.parsed_definitions,
diff --git a/airflow-core/src/airflow/models/dagwarning.py
b/airflow-core/src/airflow/models/dagwarning.py
index 6305361d2aa..4d1eeac7db5 100644
--- a/airflow-core/src/airflow/models/dagwarning.py
+++ b/airflow-core/src/airflow/models/dagwarning.py
@@ -108,6 +108,7 @@ class DagWarningType(str, Enum):
DUPLICATE_DAG_ID = "duplicate dag id"
NONEXISTENT_POOL = "non-existent pool"
RUNTIME_VARYING_VALUE = "runtime varying value"
+ TASK_GROUP_CYCLE = "task group cycle"
ImporterWarningType = Annotated[
diff --git a/airflow-core/src/airflow/ui/openapi-gen/requests/schemas.gen.ts
b/airflow-core/src/airflow/ui/openapi-gen/requests/schemas.gen.ts
index f3651041f84..68ccfe0903b 100644
--- a/airflow-core/src/airflow/ui/openapi-gen/requests/schemas.gen.ts
+++ b/airflow-core/src/airflow/ui/openapi-gen/requests/schemas.gen.ts
@@ -5618,7 +5618,7 @@ export const $DagVersionResponse = {
export const $DagWarningType = {
type: 'string',
- enum: ['asset conflict', 'duplicate dag id', 'non-existent pool', 'runtime
varying value'],
+ enum: ['asset conflict', 'duplicate dag id', 'non-existent pool', 'runtime
varying value', 'task group cycle'],
title: 'DagWarningType',
description: `Enum for DAG warning types.
diff --git a/airflow-core/src/airflow/ui/openapi-gen/requests/types.gen.ts
b/airflow-core/src/airflow/ui/openapi-gen/requests/types.gen.ts
index c27c63231e5..bc19ad00938 100644
--- a/airflow-core/src/airflow/ui/openapi-gen/requests/types.gen.ts
+++ b/airflow-core/src/airflow/ui/openapi-gen/requests/types.gen.ts
@@ -1555,7 +1555,7 @@ export type DagVersionResponse = {
* This is the set of allowable values for the ``warning_type`` field
* in the DagWarning model.
*/
-export type DagWarningType = 'asset conflict' | 'duplicate dag id' |
'non-existent pool' | 'runtime varying value';
+export type DagWarningType = 'asset conflict' | 'duplicate dag id' |
'non-existent pool' | 'runtime varying value' | 'task group cycle';
/**
* How much of a component's work has a live instance covering it.
diff --git
a/airflow-core/tests/unit/api_fastapi/core_api/routes/public/test_dag_warning.py
b/airflow-core/tests/unit/api_fastapi/core_api/routes/public/test_dag_warning.py
index 7a15de834c9..e96311a666c 100644
---
a/airflow-core/tests/unit/api_fastapi/core_api/routes/public/test_dag_warning.py
+++
b/airflow-core/tests/unit/api_fastapi/core_api/routes/public/test_dag_warning.py
@@ -115,9 +115,9 @@ class TestGetDagWarnings:
response = test_client.get("/dagWarnings", params={"warning_type":
"invalid"})
response_json = response.json()
assert response.status_code == 422
- assert (
- response_json["detail"][0]["msg"]
- == "Input should be 'asset conflict', 'duplicate dag id',
'non-existent pool' or 'runtime varying value'"
+ assert response_json["detail"][0]["msg"] == (
+ "Input should be 'asset conflict', 'duplicate dag id',
'non-existent pool', "
+ "'runtime varying value' or 'task group cycle'"
)
def test_get_dag_warnings_non_matching_type(self, test_client):
diff --git a/airflow-core/tests/unit/dag_processing/test_collection.py
b/airflow-core/tests/unit/dag_processing/test_collection.py
index 8a7fe712bb5..6e9a61c8756 100644
--- a/airflow-core/tests/unit/dag_processing/test_collection.py
+++ b/airflow-core/tests/unit/dag_processing/test_collection.py
@@ -994,6 +994,30 @@ class TestUpdateDagParsingResults:
dag_code = DagCode.get_latest_dagcode("yaml_dag", session=session)
assert (dag_code.source_code, dag_code.language) == ("dag_id:
yaml_dag\n", "yaml")
+ def test_task_group_cycle_warning_is_removed_when_dag_is_fixed(self,
testing_dag_bundle, session):
+ dag = DAG(dag_id="fixed_dag")
+ session.add(DagModel(dag_id="fixed_dag", bundle_name="testing",
is_stale=False))
+ session.add(
+ DagWarning(
+ dag_id="fixed_dag",
+ warning_type=DagWarningType.TASK_GROUP_CYCLE,
+ message="Dag 'fixed_dag': group and bridge depend on each
other in a cycle.",
+ )
+ )
+ session.flush()
+
+ update_dag_parsing_results_in_db(
+ bundle_name="testing",
+ bundle_version=None,
+ dags=[LazyDeserializedDAG.from_dag(dag)],
+ import_errors={},
+ parse_duration=None,
+ warnings=set(),
+ session=session,
+ )
+
+ assert session.scalar(select(DagWarning).where(DagWarning.dag_id ==
"fixed_dag")) is None
+
def test_parse_time_written_to_db_on_sync(self, testing_dag_bundle,
session):
"""Test that the parse time is correctly written to the DB after
parsing"""
diff --git a/airflow-core/tests/unit/dag_processing/test_dagbag.py
b/airflow-core/tests/unit/dag_processing/test_dagbag.py
index dc02e9ea085..73a718580f0 100644
--- a/airflow-core/tests/unit/dag_processing/test_dagbag.py
+++ b/airflow-core/tests/unit/dag_processing/test_dagbag.py
@@ -44,15 +44,15 @@ from airflow.dag_processing.dagbag import (
)
from airflow.dag_processing.lang_sdk_processor import
LangSDKDagFileProcessorProcess
from airflow.dag_processing.processor import DagFileParsingResult
-from airflow.exceptions import UnknownExecutorException
+from airflow.exceptions import AirflowDagDuplicatedIdException,
UnknownExecutorException
from airflow.executors.executor_loader import ExecutorLoader
from airflow.models.dag import DagModel
from airflow.models.dagwarning import DagWarning, DagWarningType
from airflow.models.errors import ParseImportError
from airflow.models.pool import Pool
from airflow.models.serialized_dag import SerializedDagModel
-from airflow.sdk import DAG, BaseOperator
-from airflow.sdk.exceptions import AirflowConfigException
+from airflow.sdk import DAG, BaseOperator, TaskGroup
+from airflow.sdk.exceptions import AirflowConfigException,
TaskGroupCycleDeprecationWarning
from airflow.sdk.importers import (
AbstractDagImporter,
DagImportResult,
@@ -1402,6 +1402,58 @@ with airflow.DAG(
os.fspath(dag_file): (f"{dag_file}:1: test:deprecated_field:
Deprecated field",)
}
+ @staticmethod
+ def _make_task_group_cycle_dag(*, cyclic: bool) -> DAG:
+ with DAG(dag_id="test") as dag:
+ with TaskGroup("group") as group:
+ a = BaseOperator(task_id="a")
+ b = BaseOperator(task_id="b")
+ a >> BaseOperator(task_id="bridge", task_group=None if cyclic else
group) >> b
+ return dag
+
+ def test_dag_warnings_task_group_cycle(self):
+ dagbag = DagBag(dag_folder="", collect_dags=False)
+
+ # Python ignores DeprecationWarning outside __main__, as in the Dag
processor.
+ with warnings.catch_warnings():
+ warnings.simplefilter("ignore")
+ dagbag.bag_dag(self._make_task_group_cycle_dag(cyclic=True))
+
+ (warning,) = dagbag.dag_warnings
+ assert (warning.dag_id, warning.warning_type) == ("test",
DagWarningType.TASK_GROUP_CYCLE)
+ assert "group and bridge depend on each other in a cycle." in
warning.message
+
+ with pytest.warns(TaskGroupCycleDeprecationWarning):
+ dagbag.bag_dag(self._make_task_group_cycle_dag(cyclic=True))
+
+ dagbag.bag_dag(self._make_task_group_cycle_dag(cyclic=False))
+ assert dagbag.dag_warnings == set()
+
+ @pytest.mark.parametrize(
+ ("bagged_cyclic", "duplicate_cyclic"),
+ [
+ pytest.param(False, True, id="cyclic-duplicate-of-acyclic"),
+ pytest.param(True, False, id="acyclic-duplicate-of-cyclic"),
+ ],
+ )
+ def test_dag_warnings_task_group_cycle_ignores_rejected_duplicate(self,
bagged_cyclic, duplicate_cyclic):
+ dagbag = DagBag(dag_folder="", collect_dags=False)
+ bagged = self._make_task_group_cycle_dag(cyclic=bagged_cyclic)
+ bagged.fileloc = "/dags/bagged.py"
+ duplicate = self._make_task_group_cycle_dag(cyclic=duplicate_cyclic)
+ duplicate.fileloc = "/dags/duplicate.py"
+
+ with warnings.catch_warnings():
+ warnings.simplefilter("ignore")
+ dagbag.bag_dag(bagged)
+ with pytest.raises(AirflowDagDuplicatedIdException):
+ dagbag.bag_dag(duplicate)
+
+ assert dagbag.dags["test"] is bagged
+ assert {w.warning_type for w in dagbag.dag_warnings} == (
+ {DagWarningType.TASK_GROUP_CYCLE} if bagged_cyclic else set()
+ )
+
def test_sigsegv_handling(self, tmp_path, caplog):
"""
Test that a SIGSEGV in a DAG file is handled gracefully and does not
crash the process.
diff --git a/airflow-core/tests/unit/dag_processing/test_processor.py
b/airflow-core/tests/unit/dag_processing/test_processor.py
index f5abf5f8b52..629cb6b1c49 100644
--- a/airflow-core/tests/unit/dag_processing/test_processor.py
+++ b/airflow-core/tests/unit/dag_processing/test_processor.py
@@ -794,6 +794,41 @@ def
test_parse_file_reports_definitions_source_codes_and_dag_warnings(mock_dag_w
]
+TASK_GROUP_CYCLE_DAGS = """
+from airflow.sdk import DAG, BaseOperator, TaskGroup
+
+for dag_id in ("cyclic", "acyclic", "unserializable"):
+ with DAG(dag_id, schedule=None):
+ with TaskGroup("group") as group:
+ a = BaseOperator(task_id="a")
+ b = BaseOperator(task_id="b")
+ a >> BaseOperator(task_id="bridge", task_group=group if dag_id ==
"acyclic" else None) >> b
+"""
+
+
+def test_parse_file_task_group_cycle_warning(tmp_path):
+ dag_file = tmp_path / "task_group_cycle_dags.py"
+ dag_file.write_text(TASK_GROUP_CYCLE_DAGS)
+ to_dict = DagSerialization.to_dict
+
+ def fail_to_serialize_unserializable(dag):
+ if dag.dag_id == "unserializable":
+ raise ValueError("serialization failed")
+ return to_dict(dag)
+
+ with patch.object(
+ DagSerialization, "to_dict", autospec=True,
side_effect=fail_to_serialize_unserializable
+ ):
+ result = _parse_file(
+ DagFileParseRequest(file=str(dag_file), bundle_path=tmp_path,
bundle_name="testing"),
+ log=structlog.get_logger(),
+ )
+
+ assert [(warning["dag_id"], warning["warning_type"]) for warning in
result.warnings] == [
+ ("cyclic", "task group cycle")
+ ]
+
+
def test_callback_processing_does_not_update_timestamps():
"""Callback processing should not update last_finish_time to prevent stale
DAG detection."""
stat = process_parse_results(
diff --git a/airflow-ctl/src/airflowctl/api/datamodels/generated.py
b/airflow-ctl/src/airflowctl/api/datamodels/generated.py
index edef5aed5a0..337a83c76cc 100644
--- a/airflow-ctl/src/airflowctl/api/datamodels/generated.py
+++ b/airflow-ctl/src/airflowctl/api/datamodels/generated.py
@@ -911,6 +911,7 @@ class DagWarningType(str, Enum):
DUPLICATE_DAG_ID = "duplicate dag id"
NON_EXISTENT_POOL = "non-existent pool"
RUNTIME_VARYING_VALUE = "runtime varying value"
+ TASK_GROUP_CYCLE = "task group cycle"
class DetailedHealthStatus(str, Enum):
diff --git a/task-sdk/src/airflow/sdk/definitions/dag.py
b/task-sdk/src/airflow/sdk/definitions/dag.py
index 14ddc937dda..0594ac80658 100644
--- a/task-sdk/src/airflow/sdk/definitions/dag.py
+++ b/task-sdk/src/airflow/sdk/definitions/dag.py
@@ -59,6 +59,7 @@ from airflow.sdk.exceptions import (
NodeNotFound,
ParamValidationError,
RemovedInAirflow4Warning,
+ TaskGroupCycleDeprecationWarning,
TaskNotFound,
)
@@ -87,6 +88,11 @@ log = logging.getLogger(__name__)
TAG_MAX_LEN = 100
+# The TaskGroup cycle warning becomes a DagWarning.message, a Text column (64
KB on MySQL), so its
+# length must not grow with the size or the number of the cycles.
+_MAX_LISTED_TASK_GROUP_CYCLES = 10
+_MAX_LISTED_TASK_GROUP_CYCLE_MEMBERS = 5
+
__all__ = [
"DAG",
"dag",
@@ -305,6 +311,13 @@ def _default_task_group(instance: DAG) -> TaskGroup:
return TaskGroup.create_root(dag=instance)
+def _format_cycle_members(node_ids: list[str]) -> str:
+ if len(node_ids) > _MAX_LISTED_TASK_GROUP_CYCLE_MEMBERS:
+ unlisted = len(node_ids) - _MAX_LISTED_TASK_GROUP_CYCLE_MEMBERS
+ return f"{', '.join(node_ids[:_MAX_LISTED_TASK_GROUP_CYCLE_MEMBERS])}
and {unlisted} more"
+ return f"{', '.join(node_ids[:-1])} and {node_ids[-1]}"
+
+
def _is_valid_dag_result(value: Any) -> TypeIs[PlainXComArg]:
from airflow.sdk.bases.xcom import BaseXCom
from airflow.sdk.definitions.xcom_arg import PlainXComArg
@@ -1149,6 +1162,9 @@ class DAG:
"""
Check to see if there are any cycles in the Dag.
+ Also warns with ``TaskGroupCycleDeprecationWarning`` when TaskGroups
depend on each
+ other in a cycle, which is deprecated.
+
:raises AirflowDagCycleException: If cycle is found in the Dag.
"""
task_dict = self.task_dict
@@ -1158,6 +1174,35 @@ class DAG:
f"Cycle detected in Dag: {self.dag_id}. Faulty task:
{faulty_task_id}"
)
+ self._warn_task_group_cycles()
+
+ def _warn_task_group_cycles(self) -> None:
+ task_group_dict = self.task_group.get_task_group_dict()
+ # With only the root group, the projection is the task graph, which is
acyclic here.
+ if len(task_group_dict) == 1:
+ return
+ cycles = [
+ cycle
+ for task_group in task_group_dict.values()
+ for cycle in
task_group._find_dependency_cycles(group_dict=task_group_dict)
+ ]
+ if not cycles:
+ return
+ listed = "; ".join(_format_cycle_members(cycle) for cycle in
cycles[:_MAX_LISTED_TASK_GROUP_CYCLES])
+ unlisted = len(cycles) - _MAX_LISTED_TASK_GROUP_CYCLES
+ unlisted_note = (
+ f" ({unlisted} more cycle{'s' if unlisted > 1 else ''} not
listed)" if unlisted > 0 else ""
+ )
+ # Airflow 3.5 raises AirflowDagCycleException here instead; tracked at
+ # https://github.com/apache/airflow/issues/73678
+ warnings.warn(
+ f"Dag '{self.dag_id}': {listed} depend on each other in a
cycle{unlisted_note}. Cyclic TaskGroup "
+ "dependencies are deprecated and will fail Dag parsing in Airflow
3.5. See "
+ '"Cyclic TaskGroup dependencies" in the docs.',
+ TaskGroupCycleDeprecationWarning,
+ stacklevel=3,
+ )
+
def cli(self):
"""Exposes a CLI specific to this Dag."""
self.check_cycle()
diff --git a/task-sdk/src/airflow/sdk/definitions/taskgroup.py
b/task-sdk/src/airflow/sdk/definitions/taskgroup.py
index 21417e75b01..90689d1309d 100644
--- a/task-sdk/src/airflow/sdk/definitions/taskgroup.py
+++ b/task-sdk/src/airflow/sdk/definitions/taskgroup.py
@@ -23,7 +23,7 @@ import copy
import re
import weakref
from collections import deque
-from collections.abc import Generator, Iterator, Sequence
+from collections.abc import Collection, Generator, Iterator, Sequence
from typing import TYPE_CHECKING, Any
import attrs
@@ -595,7 +595,15 @@ class TaskGroup(TaskGroupMixin, DAGNode):
id_to_idx: dict[str, int],
group_dict: dict[str, TaskGroup],
) -> tuple[int, ...]:
- upstream_ids = child._topological_upstream_ids
+ return self._project_upstream_ids(child_idx,
child._topological_upstream_ids, id_to_idx, group_dict)
+
+ def _project_upstream_ids(
+ self,
+ child_idx: int,
+ upstream_ids: Collection[str],
+ id_to_idx: dict[str, int],
+ group_dict: dict[str, TaskGroup],
+ ) -> tuple[int, ...]:
if not upstream_ids:
return ()
sib_deps: set[int] = set()
@@ -669,6 +677,39 @@ class TaskGroup(TaskGroupMixin, DAGNode):
return self._sort_cyclic_projection(nodes, projected)
return [nodes[i] for i in sorted_indices]
+ def _find_dependency_cycles(self, *, group_dict: dict[str, TaskGroup]) ->
list[list[str]]:
+ """
+ Find children that depend on each other in a cycle when each child
TaskGroup is one unit.
+
+ An edge into any task of a child group counts as an edge into the
group, so a path that
+ leaves a group and comes back into it is a cycle even though the
task-level graph is
+ acyclic. This is stricter than the ordering ``topological_sort``
needs, which only looks
+ at edges into a group's roots.
+
+ :return: one list of child node ids per cycle, each in insertion order
+ """
+ nodes = list(self.children.values())
+ id_to_idx = {nid: i for i, nid in enumerate(self.children)}
+ projected = [
+ self._project_upstream_ids(i, self._get_unit_upstream_ids(child),
id_to_idx, group_dict)
+ for i, child in enumerate(nodes)
+ ]
+ members: dict[int, list[str]] = {}
+ for i, component in
enumerate(self._find_projection_components(projected)):
+ members.setdefault(component, []).append(nodes[i].node_id)
+ return [node_ids for node_ids in members.values() if len(node_ids) > 1]
+
+ @staticmethod
+ def _get_unit_upstream_ids(child: DAGNode) -> Collection[str]:
+ if not isinstance(child, TaskGroup):
+ return child._topological_upstream_ids
+ # Not _topological_upstream_ids or iter(child):
MappedTaskGroup.__iter__ raises for a
+ # trigger_rule="always" child, which would turn an acyclic Dag into an
import error.
+ upstream_ids = set(child.upstream_task_ids)
+ upstream_ids.update(group_id for group_id in child.upstream_group_ids
if group_id is not None)
+ upstream_ids.update(edge_id for task in child.iter_tasks() for edge_id
in task.upstream_task_ids)
+ return upstream_ids
+
def iter_mapped_task_groups(self) -> Iterator[MappedTaskGroup]:
"""
Return mapped task groups in the hierarchy.
diff --git a/task-sdk/src/airflow/sdk/exceptions.py
b/task-sdk/src/airflow/sdk/exceptions.py
index 6f43d5421ec..78a7c491c99 100644
--- a/task-sdk/src/airflow/sdk/exceptions.py
+++ b/task-sdk/src/airflow/sdk/exceptions.py
@@ -427,3 +427,7 @@ class RemovedInAirflow4Warning(DeprecationWarning):
deprecated_since: str | None = None
"Indicates the airflow version that started raising this deprecation
warning"
+
+
+class TaskGroupCycleDeprecationWarning(DeprecationWarning):
+ """Issued for Dags whose TaskGroups depend on each other in a cycle;
Airflow 3.5 will reject them."""
diff --git a/task-sdk/tests/task_sdk/definitions/test_dag.py
b/task-sdk/tests/task_sdk/definitions/test_dag.py
index 66dc9545d50..be9975f1b93 100644
--- a/task-sdk/tests/task_sdk/definitions/test_dag.py
+++ b/task-sdk/tests/task_sdk/definitions/test_dag.py
@@ -33,6 +33,7 @@ from airflow.sdk import (
Param,
PartitionedAtRuntime,
TaskGroup,
+ TriggerRule,
dag as dag_decorator,
task,
task_group,
@@ -41,7 +42,12 @@ from airflow.sdk.bases.operator import BaseOperator
from airflow.sdk.bases.timetable import BaseTimetable
from airflow.sdk.definitions._internal.loop import create_loop
from airflow.sdk.definitions.param import DagParam, ParamsDict
-from airflow.sdk.exceptions import AirflowDagCycleException,
DuplicateTaskIdFound, RemovedInAirflow4Warning
+from airflow.sdk.exceptions import (
+ AirflowDagCycleException,
+ DuplicateTaskIdFound,
+ RemovedInAirflow4Warning,
+ TaskGroupCycleDeprecationWarning,
+)
from airflow.utils.types import DagRunType
DEFAULT_DATE = datetime(2016, 1, 1, tzinfo=UTC)
@@ -83,14 +89,15 @@ 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."""
+ """A TaskGroup that depends on a sibling in a cycle, which check_cycle
only warns about, 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()
+ with pytest.warns(TaskGroupCycleDeprecationWarning):
+ dag.check_cycle()
assert [task.task_id for task in dag.topological_sort()] == ["bridge",
"group.a", "group.b"]
@@ -473,7 +480,6 @@ class TestDag:
def test_dag_add_task_checks_trigger_rule(self):
# A non fail stop dag should allow any trigger rule
- from airflow.sdk import TriggerRule
from airflow.sdk.exceptions import FailFastDagInvalidTriggerRule
class CustomOperator(BaseOperator):
@@ -988,6 +994,134 @@ class DoNothingOperator(BaseOperator):
pass
+def _add_sibling_groups_cycle():
+ with TaskGroup("group1"):
+ a1 = DoNothingOperator(task_id="a1")
+ a2 = DoNothingOperator(task_id="a2")
+ with TaskGroup("group2"):
+ b1 = DoNothingOperator(task_id="b1")
+ b2 = DoNothingOperator(task_id="b2")
+ a1 >> b1
+ b2 >> a2
+ return [a1, b2], [a2, b1]
+
+
+def _make_sibling_groups_cycle_dag():
+ with DAG("dag", schedule=None) as dag:
+ firsts, lasts = _add_sibling_groups_cycle()
+ DoNothingOperator(task_id="start") >> firsts
+ lasts >> DoNothingOperator(task_id="end")
+ return dag
+
+
+def _make_group_bridged_by_outside_task_dag():
+ with DAG("dag", schedule=None) as dag:
+ with TaskGroup("group"):
+ a = DoNothingOperator(task_id="a")
+ b = DoNothingOperator(task_id="b")
+ a >> DoNothingOperator(task_id="bridge") >> b
+ return dag
+
+
+def _make_group_reentered_after_in_group_upstream_dag():
+ with DAG("dag", schedule=None) as dag:
+ with TaskGroup("group"):
+ a = DoNothingOperator(task_id="a")
+ b = DoNothingOperator(task_id="b")
+ a >> b
+ a >> DoNothingOperator(task_id="bridge") >> b
+ return dag
+
+
+def _make_sibling_groups_cycle_through_non_roots_dag():
+ with DAG("dag", schedule=None) as dag:
+ with TaskGroup("group1"):
+ a1 = DoNothingOperator(task_id="a1")
+ a2 = DoNothingOperator(task_id="a2")
+ a1 >> a2
+ with TaskGroup("group2"):
+ b1 = DoNothingOperator(task_id="b1")
+ b2 = DoNothingOperator(task_id="b2")
+ b1 >> b2
+ a1 >> b2
+ b1 >> a2
+ return dag
+
+
+def _make_three_group_ring_dag():
+ with DAG("dag", schedule=None) as dag:
+ tasks = {}
+ for group_id in ("g0", "g1", "g2"):
+ with TaskGroup(group_id):
+ tasks[group_id] = (DoNothingOperator(task_id="first"),
DoNothingOperator(task_id="second"))
+ tasks["g1"][0] >> tasks["g0"][1]
+ tasks["g2"][0] >> tasks["g1"][1]
+ tasks["g0"][0] >> tasks["g2"][1]
+ return dag
+
+
+def _make_two_cycles_dag():
+ with DAG("dag", schedule=None) as dag:
+ _add_sibling_groups_cycle()
+ with TaskGroup("outer"):
+ _add_sibling_groups_cycle()
+ return dag
+
+
+def _make_setup_teardown_dag(*, work_in_group: bool):
+ with DAG("dag", schedule=None) as dag:
+ with TaskGroup("cluster") as cluster:
+ create = DoNothingOperator(task_id="create")
+ delete = DoNothingOperator(task_id="delete")
+ work = DoNothingOperator(task_id="work", task_group=cluster if
work_in_group else None)
+ create >> work >> delete.as_teardown(setups=create)
+ return dag
+
+
+def _make_setup_teardown_around_many_tasks_dag():
+ with DAG("dag", schedule=None) as dag:
+ with TaskGroup("cluster"):
+ create = DoNothingOperator(task_id="create")
+ delete = DoNothingOperator(task_id="delete")
+ work = [DoNothingOperator(task_id=f"work_{i}") for i in range(100)]
+ create >> work >> delete.as_teardown(setups=create)
+ return dag
+
+
+def _make_many_bridged_groups_dag(count: int):
+ with DAG("dag", schedule=None) as dag:
+ for i in range(count):
+ with TaskGroup(f"group_{i}"):
+ a = DoNothingOperator(task_id="a")
+ b = DoNothingOperator(task_id="b")
+ a >> DoNothingOperator(task_id=f"bridge_{i}") >> b
+ return dag
+
+
+def _make_group_with_bridge_inside_dag():
+ with DAG("dag", schedule=None) as dag:
+ with TaskGroup("group"):
+ a = DoNothingOperator(task_id="a")
+ b = DoNothingOperator(task_id="b")
+ a >> DoNothingOperator(task_id="bridge") >> b
+ return dag
+
+
+def _make_mapped_group_with_always_task_dag():
+ with DAG("dag", schedule=None) as dag:
+
+ @task(trigger_rule=TriggerRule.ALWAYS)
+ def work(value):
+ return value
+
+ @task_group
+ def group(value):
+ work(value)
+
+ group.expand(value=[1, 2, 3])
+ return dag
+
+
class TestCycleTester:
def test_cycle_empty(self):
# test empty
@@ -1129,6 +1263,92 @@ class TestCycleTester:
assert not dag.check_cycle()
+ @pytest.mark.parametrize(
+ ("make_dag", "expected_cycles"),
+ [
+ pytest.param(_make_sibling_groups_cycle_dag, "group1 and group2",
id="sibling-groups"),
+ pytest.param(_make_group_bridged_by_outside_task_dag, "group and
bridge", id="bridged-group"),
+ pytest.param(
+ _make_group_reentered_after_in_group_upstream_dag,
+ "group and bridge",
+ id="group-reentered-after-in-group-upstream",
+ ),
+ pytest.param(
+ _make_sibling_groups_cycle_through_non_roots_dag,
+ "group1 and group2",
+ id="sibling-groups-through-non-roots",
+ ),
+ pytest.param(_make_three_group_ring_dag, "g0, g1 and g2",
id="three-group-ring"),
+ pytest.param(
+ _make_two_cycles_dag,
+ "group1 and group2; outer.group1 and outer.group2",
+ id="root-and-nested-cycles",
+ ),
+ pytest.param(
+ lambda: _make_setup_teardown_dag(work_in_group=False),
+ "cluster and work",
+ id="setup-teardown-around-outside-task",
+ ),
+ ],
+ )
+ def test_task_group_cycle_warns(self, make_dag, expected_cycles):
+ dag = make_dag()
+
+ with pytest.warns(TaskGroupCycleDeprecationWarning) as record:
+ dag.check_cycle()
+
+ assert len(record) == 1
+ assert str(record[0].message).startswith(
+ f"Dag 'dag': {expected_cycles} depend on each other in a cycle. "
+ )
+
+ @pytest.mark.parametrize(
+ ("make_dag", "expected_start"),
+ [
+ pytest.param(
+ _make_setup_teardown_around_many_tasks_dag,
+ "Dag 'dag': cluster, work_0, work_1, work_2, work_3 and 96
more depend on each other in a "
+ "cycle. ",
+ id="cycle-members",
+ ),
+ pytest.param(
+ lambda: _make_many_bridged_groups_dag(11),
+ f"Dag 'dag': {'; '.join(f'group_{i} and bridge_{i}' for i in
range(10))} depend on each "
+ "other in a cycle (1 more cycle not listed). ",
+ id="one-unlisted-cycle",
+ ),
+ pytest.param(
+ lambda: _make_many_bridged_groups_dag(12),
+ f"Dag 'dag': {'; '.join(f'group_{i} and bridge_{i}' for i in
range(10))} depend on each "
+ "other in a cycle (2 more cycles not listed). ",
+ id="unlisted-cycles",
+ ),
+ ],
+ )
+ def test_task_group_cycle_warning_caps_listed_ids(self, make_dag,
expected_start):
+ dag = make_dag()
+
+ with pytest.warns(TaskGroupCycleDeprecationWarning) as record:
+ dag.check_cycle()
+
+ assert len(record) == 1
+ assert str(record[0].message).startswith(expected_start)
+
+ @pytest.mark.parametrize(
+ "make_dag",
+ [
+ pytest.param(_make_group_with_bridge_inside_dag,
id="bridge-inside-group"),
+ pytest.param(lambda: _make_setup_teardown_dag(work_in_group=True),
id="setup-teardown-in-group"),
+ pytest.param(_make_mapped_group_with_always_task_dag,
id="mapped-group-with-always-task"),
+ ],
+ )
+ def test_acyclic_task_groups_do_not_warn(self, make_dag):
+ dag = make_dag()
+
+ with warnings.catch_warnings():
+ warnings.simplefilter("error", TaskGroupCycleDeprecationWarning)
+ dag.check_cycle()
+
class TestDagGetItem:
def test_getitem_returns_task(self):