Repository: apache/airflow

Issue:
DuplicateTaskIdFound when reusing tasks with task_group decorator
### Apache Airflow version

2.2.2 (latest released)

### Operating System

MacOS 11.6.1

### Versions of Apache Airflow Providers

$ pip freeze | grep airflow
apache-airflow==2.2.2
apache-airflow-providers-celery==2.1.0
apache-airflow-providers-ftp==2.0.1
apache-airflow-providers-http==2.0.1
apache-airflow-providers-imap==2.0.1
apache-airflow-providers-sqlite==2.0.1

### Deployment

Other

### Deployment details

`airflow standalone`

### What happened

Exception raised :     

```
raise DuplicateTaskIdFound(f"Task id '{key}' has already been added to the DAG")
airflow.exceptions.DuplicateTaskIdFound: Task id 'do_all_things.do_thing__1' has already been added to the DAG
```

### What you expected to happen

_No response_

### How to reproduce

* Add the following python file to the `dags` folder:

```python
from datetime import datetime

from airflow.decorators import dag, task, task_group


@dag(start_date=datetime(2023, 1, 1), schedule_interval="@once")
def test_dag_1():
    @task
    def start():
        pass

    @task
    def do_thing(x):
        print(x)

    @task_group
    def do_all_things():
        for i in range(5):
            do_thing(i)

    @task
    def end():
        pass

    start() >> do_all_things() >> end()


test_dag_1()
```

* Start airflow by running `airflow standalone`

### Anything else

_No response_

### Are you willing to submit PR?

- [ ] Yes I am willing to submit a PR!

### Code of Conduct

- [X] I agree to follow this project's [Code of Conduct](https://github.com/apache/airflow/blob/main/CODE_OF_CONDUCT.md)



Discussion:
Thanks for opening your first issue here! Be sure to follow the issue template!

I just noticed that the above works if I ditch the for loop in the _task_group_, and run each _task_ separately. 
Yeah, this is a known problem (well, I know it, at least) with `@task`, you need to instead do something like

```python
@task_group
def do_all_things():
    for i in range(5):
        @task(task_id=f"do_thing_{i}")
        def do_thing(i):
            print(i)
 
        do_thing(i)
```

instead. But it’s unfortunately not very easy to fix.
(Self-assigning so maybe I can come back to it at some point. But feel free to raise your hand if you want to tak a stab instead!)

Resolving patch:
diff --git a/airflow/decorators/base.py b/airflow/decorators/base.py
--- a/airflow/decorators/base.py
+++ b/airflow/decorators/base.py
@@ -26,6 +26,7 @@
     Collection,
     Dict,
     Generic,
+    Iterator,
     Mapping,
     Optional,
     Sequence,
@@ -46,7 +47,7 @@
 from airflow.utils.task_group import TaskGroup, TaskGroupContext
 
 
-def validate_python_callable(python_callable):
+def validate_python_callable(python_callable: Any) -> None:
     """
     Validate that python callable can be wrapped by operator.
     Raises exception if invalid.
@@ -61,7 +62,9 @@ def validate_python_callable(python_callable):
 
 
 def get_unique_task_id(
-    task_id: str, dag: Optional[DAG] = None, task_group: Optional[TaskGroup] = None
+    task_id: str,
+    dag: Optional[DAG] = None,
+    task_group: Optional[TaskGroup] = None,
 ) -> str:
     """
     Generate unique task id given a DAG (or if run in a DAG context)
@@ -86,15 +89,18 @@ def get_unique_task_id(
 
     if tg_task_id not in dag.task_ids:
         return task_id
-    core = re.split(r'__\d+$', task_id)[0]
-    suffixes = sorted(
-        int(re.split(r'^.+__', task_id)[1])
-        for task_id in dag.task_ids
-        if re.match(rf'^{core}__\d+$', task_id)
-    )
-    if not suffixes:
-        return f'{core}__1'
-    return f'{core}__{suffixes[-1] + 1}'
+
+    def _find_id_suffixes(dag: DAG) -> Iterator[int]:
+        prefix = re.split(r"__\d+$", tg_task_id)[0]
+        for task_id in dag.task_ids:
+            match = re.match(rf"^{prefix}__(\d+)$", task_id)
+            if match is None:
+                continue
+            yield int(match.group(1))
+        yield 0  # Default if there's no matching task ID.
+
+    core = re.split(r"__\d+$", task_id)[0]
+    return f"{core}__{max(_find_id_suffixes(dag)) + 1}"
 
 
 class DecoratedOperator(BaseOperator):
@@ -136,7 +142,7 @@ def __init__(
         kwargs_to_upstream: Optional[Dict[str, Any]] = None,
         **kwargs,
     ) -> None:
-        kwargs['task_id'] = get_unique_task_id(task_id, kwargs.get('dag'), kwargs.get('task_group'))
+        task_id = get_unique_task_id(task_id, kwargs.get('dag'), kwargs.get('task_group'))
         self.python_callable = python_callable
         kwargs_to_upstream = kwargs_to_upstream or {}
         op_args = op_args or []
@@ -147,7 +153,7 @@ def __init__(
         self.multiple_outputs = multiple_outputs
         self.op_args = op_args
         self.op_kwargs = op_kwargs
-        super().__init__(**kwargs_to_upstream, **kwargs)
+        super().__init__(task_id=task_id, **kwargs_to_upstream, **kwargs)
 
     def execute(self, context: Context):
         return_value = super().execute(context)


Test patch:
diff --git a/tests/utils/test_task_group.py b/tests/utils/test_task_group.py
--- a/tests/utils/test_task_group.py
+++ b/tests/utils/test_task_group.py
@@ -1096,6 +1096,32 @@ def tg():
             ...
 
 
+def test_decorator_multiple_use_task():
+    from airflow.decorators import task
+
+    @dag("test-dag", start_date=DEFAULT_DATE)
+    def _test_dag():
+        @task
+        def t():
+            pass
+
+        @task_group_decorator
+        def tg():
+            for _ in range(3):
+                t()
+
+        t() >> tg() >> t()
+
+    test_dag = _test_dag()
+    assert test_dag.task_ids == [
+        "t",  # Start end.
+        "tg.t",
+        "tg.t__1",
+        "tg.t__2",
+        "t__1",  # End node.
+    ]
+
+
 def test_decorator_partial_unmapped():
     @task_group_decorator
     def tg():