Repository: apache/airflow

Issue:
Improve idempodency in CloudDataTransferServiceCreateJobOperator
**Description**

We should improve idempodency in [CloudDataTransferServiceCreateJobOperator](https://github.com/apache/airflow/blob/42eef38/airflow/providers/google/cloud/operators/cloud_storage_transfer_service.py#L157). If a Job ID is passed and an AlreadyExist exception raises, then the task should be successful.

This issue is due to [the March 9 service update](https://cloud.google.com/storage-transfer/docs/release-notes#March_09_2020).

> FEATURE: Storage Transfer Service supports custom job IDs.

To complete this task you must complete the following steps:
- [ ] Update the operator
- [ ] Update the example DAG
- [ ] Update the documentation
- [ ] Run system tests (final check)

If you haven't used the GCP yet, after creating the account you will [get $300](https://cloud.google.com/free), which will allow you to get to know these services better.

The implementation of this task will allow a better understanding of GCP services, as well as learn methods of testing that is required by the community. If anyone is interested in this task, I am willing to provide all the necessary tips and information.

**Use case / motivation**

N/A

**Related Issues**

https://github.com/apache/airflow/issues/8284


Discussion:
I can get it, 
Looks like easy and helpful 
Still in progress. 

Right now i've got some troubles reproducing `AlreadyExists` exception:

1. Failed to obtain the location of the source S3 bucket. Additional details: Access Denied. 
But S3 credentials are valid.

2. Not clear how to setup GCP. I've created service account add added owner permission to bucket, but still have error 

```
Failed to obtain the location of the Google Cloud Storage (GCS) bucket gcp-transfer-first-target_2 due to insufficient permissions.  Please verify that the necessary permissions have been granted.
```

UPDATE: Account must have permissions `Storage Transfer Admin` or `Storage Transfer User` https://cloud.google.com/storage-transfer/docs/overview

3. `aws_hook = AwsBaseHook(self.aws_conn_id)` in `TransferJobPreprocessor ` always raises `AirflowException('Either client_type or resource_type must be provided.')`
I've hardcoded this line: `aws_hook = AwsBaseHook(self.aws_conn_id, client_type="s3")`

Resolving patch:
diff --git a/airflow/providers/google/cloud/example_dags/example_cloud_storage_transfer_service_aws.py b/airflow/providers/google/cloud/example_dags/example_cloud_storage_transfer_service_aws.py
--- a/airflow/providers/google/cloud/example_dags/example_cloud_storage_transfer_service_aws.py
+++ b/airflow/providers/google/cloud/example_dags/example_cloud_storage_transfer_service_aws.py
@@ -42,7 +42,7 @@
 from airflow import models
 from airflow.providers.google.cloud.hooks.cloud_storage_transfer_service import (
     ALREADY_EXISTING_IN_SINK, AWS_S3_DATA_SOURCE, BUCKET_NAME, DESCRIPTION, FILTER_JOB_NAMES,
-    FILTER_PROJECT_ID, GCS_DATA_SINK, PROJECT_ID, SCHEDULE, SCHEDULE_END_DATE, SCHEDULE_START_DATE,
+    FILTER_PROJECT_ID, GCS_DATA_SINK, JOB_NAME, PROJECT_ID, SCHEDULE, SCHEDULE_END_DATE, SCHEDULE_START_DATE,
     START_TIME_OF_DAY, STATUS, TRANSFER_OPTIONS, TRANSFER_SPEC, GcpTransferJobsStatus,
     GcpTransferOperationStatus,
 )
@@ -67,11 +67,16 @@
     'GCP_TRANSFER_FIRST_TARGET_BUCKET', 'gcp-transfer-first-target'
 )
 
+GCP_TRANSFER_JOB_NAME = os.environ.get(
+    'GCP_TRANSFER_JOB_NAME', 'transferJobs/sampleJob'
+)
+
 # [START howto_operator_gcp_transfer_create_job_body_aws]
 aws_to_gcs_transfer_body = {
     DESCRIPTION: GCP_DESCRIPTION,
     STATUS: GcpTransferJobsStatus.ENABLED,
     PROJECT_ID: GCP_PROJECT_ID,
+    JOB_NAME: GCP_TRANSFER_JOB_NAME,
     SCHEDULE: {
         SCHEDULE_START_DATE: datetime(2015, 1, 1).date(),
         SCHEDULE_END_DATE: datetime(2030, 1, 1).date(),
diff --git a/airflow/providers/google/cloud/hooks/cloud_storage_transfer_service.py b/airflow/providers/google/cloud/hooks/cloud_storage_transfer_service.py
--- a/airflow/providers/google/cloud/hooks/cloud_storage_transfer_service.py
+++ b/airflow/providers/google/cloud/hooks/cloud_storage_transfer_service.py
@@ -20,6 +20,7 @@
 """
 
 import json
+import logging
 import time
 import warnings
 from copy import deepcopy
@@ -27,10 +28,13 @@
 from typing import Dict, List, Optional, Set, Union
 
 from googleapiclient.discovery import build
+from googleapiclient.errors import HttpError
 
 from airflow.exceptions import AirflowException
 from airflow.providers.google.common.hooks.base_google import GoogleBaseHook
 
+log = logging.getLogger(__name__)
+
 # Time to sleep between active checks of the operation results
 TIME_TO_SLEEP_IN_SECONDS = 10
 
@@ -62,6 +66,7 @@ class GcpTransferOperationStatus:
 AWS_S3_DATA_SOURCE = 'awsS3DataSource'
 BODY = 'body'
 BUCKET_NAME = 'bucketName'
+JOB_NAME = 'name'
 DAY = 'day'
 DESCRIPTION = "description"
 FILTER = 'filter'
@@ -94,10 +99,25 @@ class GcpTransferOperationStatus:
 TRANSFER_OPTIONS = 'transfer_options'
 TRANSFER_SPEC = 'transferSpec'
 YEAR = 'year'
+ALREADY_EXIST_CODE = 409
 
 NEGATIVE_STATUSES = {GcpTransferOperationStatus.FAILED, GcpTransferOperationStatus.ABORTED}
 
 
+def gen_job_name(job_name: str) -> str:
+    """
+    Adds unique suffix to job name. If suffix already exists, updates it.
+    Suffix — current timestamp
+
+    :param job_name:
+    :rtype job_name: str
+    :return: job_name with suffix
+    :rtype: str
+    """
+    uniq = int(time.time())
+    return f"{job_name}_{uniq}"
+
+
 # noinspection PyAbstractClass
 class CloudDataTransferServiceHook(GoogleBaseHook):
     """
@@ -143,8 +163,36 @@ def create_transfer_job(self, body: Dict) -> Dict:
         :rtype: dict
         """
         body = self._inject_project_id(body, BODY, PROJECT_ID)
-        return self.get_conn().transferJobs().create(body=body).execute(  # pylint: disable=no-member
-            num_retries=self.num_retries)
+        try:
+            transfer_job = self.get_conn().transferJobs()\
+                .create(body=body).execute(  # pylint: disable=no-member
+                num_retries=self.num_retries)
+        except HttpError as e:
+            # If status code "Conflict"
+            # https://cloud.google.com/storage-transfer/docs/reference/rest/v1/transferOperations#Code.ENUM_VALUES.ALREADY_EXISTS
+            # we should try to find this job
+            job_name = body.get(JOB_NAME, "")
+            if int(e.resp.status) == ALREADY_EXIST_CODE and job_name:
+                transfer_job = self.get_transfer_job(
+                    job_name=job_name, project_id=body.get(PROJECT_ID))
+                # Generate new job_name, if jobs status is deleted
+                # and try to create this job again
+                if transfer_job.get(STATUS) == GcpTransferJobsStatus.DELETED:
+                    body[JOB_NAME] = gen_job_name(job_name)
+                    self.log.info(
+                        "Job `%s` has been soft deleted. Creating job with "
+                        "new name `%s`", job_name, {body[JOB_NAME]})
+                    # pylint: disable=no-member
+                    return self.get_conn()\
+                        .transferJobs()\
+                        .create(body=body)\
+                        .execute(num_retries=self.num_retries)
+                elif transfer_job.get(STATUS) == GcpTransferJobsStatus.DISABLED:
+                    return self.enable_transfer_job(
+                        job_name=job_name, project_id=body.get(PROJECT_ID))
+            else:
+                raise e
+        return transfer_job
 
     @GoogleBaseHook.fallback_to_default_project_id
     def get_transfer_job(self, job_name: str, project_id: str) -> Dict:
@@ -204,6 +252,34 @@ def list_transfer_job(self, request_filter: Optional[Dict] = None, **kwargs) ->
 
         return jobs
 
+    @GoogleBaseHook.fallback_to_default_project_id
+    def enable_transfer_job(self, job_name: str, project_id: str) -> Dict:
+        """
+        New transfers will be performed based on the schedule.
+
+        :param job_name: (Required) Name of the job to be updated
+        :type job_name: str
+        :param project_id: (Optional) the ID of the project that owns the Transfer
+            Job. If set to None or missing, the default project_id from the GCP
+            connection is used.
+        :type project_id: str
+        :return: If successful, TransferJob.
+        :rtype: dict
+        """
+        return (
+            self.get_conn()  # pylint: disable=no-member
+                .transferJobs()
+                .patch(
+                jobName=job_name,
+                body={
+                    PROJECT_ID: project_id,
+                    TRANSFER_JOB: {STATUS1: GcpTransferJobsStatus.ENABLED},
+                    TRANSFER_JOB_FIELD_MASK: STATUS1,
+                },
+            )
+            .execute(num_retries=self.num_retries)
+        )
+
     def update_transfer_job(self, job_name: str, body: Dict) -> Dict:
         """
         Updates a transfer job that runs periodically.
diff --git a/airflow/providers/google/cloud/operators/cloud_storage_transfer_service.py b/airflow/providers/google/cloud/operators/cloud_storage_transfer_service.py
--- a/airflow/providers/google/cloud/operators/cloud_storage_transfer_service.py
+++ b/airflow/providers/google/cloud/operators/cloud_storage_transfer_service.py
@@ -47,7 +47,7 @@ def __init__(self, body: dict, aws_conn_id: str = 'aws_default', default_schedul
 
     def _inject_aws_credentials(self):
         if TRANSFER_SPEC in self.body and AWS_S3_DATA_SOURCE in self.body[TRANSFER_SPEC]:
-            aws_hook = AwsBaseHook(self.aws_conn_id)
+            aws_hook = AwsBaseHook(self.aws_conn_id, resource_type="s3")
             aws_credentials = aws_hook.get_credentials()
             aws_access_key_id = aws_credentials.access_key
             aws_secret_access_key = aws_credentials.secret_key
@@ -160,15 +160,20 @@ class CloudDataTransferServiceCreateJobOperator(BaseOperator):
 
     .. warning::
 
-        This operator is NOT idempotent. If you run it many times, many transfer
-        jobs will be created in the Google Cloud Platform.
+        This operator is NOT idempotent in the following cases:
+
+        * `name` is not passed in body param
+        * transfer job `name` has been soft deleted. In this case,
+          each new task will receive a unique suffix
+
+        If you run it many times, many transfer jobs will be created in the Google Cloud Platform.
 
     .. seealso::
         For more information on how to use this operator, take a look at the guide:
         :ref:`howto/operator:CloudDataTransferServiceCreateJobOperator`
 
     :param body: (Required) The request body, as described in
-        https://cloud.google.com/storage-transfer/docs/reference/rest/v1/transferJobs/create#request-body
+        https://cloud.google.com/storage-transfer/docs/reference/rest/v1/transferJobs#TransferJob
         With three additional improvements:
 
         * dates can be given in the form :class:`datetime.date`


Test patch:
diff --git a/tests/providers/google/cloud/hooks/test_cloud_storage_transfer_service.py b/tests/providers/google/cloud/hooks/test_cloud_storage_transfer_service.py
--- a/tests/providers/google/cloud/hooks/test_cloud_storage_transfer_service.py
+++ b/tests/providers/google/cloud/hooks/test_cloud_storage_transfer_service.py
@@ -17,18 +17,20 @@
 # under the License.
 #
 import json
+import re
 import unittest
 from copy import deepcopy
 
 import mock
-from mock import PropertyMock
+from googleapiclient.errors import HttpError
+from mock import MagicMock, PropertyMock
 from parameterized import parameterized
 
 from airflow.exceptions import AirflowException
 from airflow.providers.google.cloud.hooks.cloud_storage_transfer_service import (
     DESCRIPTION, FILTER_JOB_NAMES, FILTER_PROJECT_ID, METADATA, OPERATIONS, PROJECT_ID, STATUS,
     TIME_TO_SLEEP_IN_SECONDS, TRANSFER_JOB, TRANSFER_JOB_FIELD_MASK, TRANSFER_JOBS,
-    CloudDataTransferServiceHook, GcpTransferJobsStatus, GcpTransferOperationStatus,
+    CloudDataTransferServiceHook, GcpTransferJobsStatus, GcpTransferOperationStatus, gen_job_name,
 )
 from tests.providers.google.cloud.utils.base_gcp_mock import (
     GCP_PROJECT_ID_HOOK_UNIT_TEST, mock_base_gcp_hook_default_project_id,
@@ -38,9 +40,11 @@
 NAME = "name"
 
 TEST_PROJECT_ID = 'project-id'
+TEST_TRANSFER_JOB_NAME = "transfer-job"
+TEST_CLEAR_JOB_NAME = "jobNames/transfer-job-clear"
+
 TEST_BODY = {DESCRIPTION: 'AAA', PROJECT_ID: TEST_PROJECT_ID}
 
-TEST_TRANSFER_JOB_NAME = "transfer-job"
 TEST_TRANSFER_OPERATION_NAME = "transfer-operation"
 
 TEST_TRANSFER_JOB = {NAME: TEST_TRANSFER_JOB_NAME}
@@ -57,6 +61,13 @@
     TRANSFER_JOB_FIELD_MASK: 'description',
 }
 
+TEST_HTTP_ERR_CODE = 409
+TEST_HTTP_ERR_CONTENT = b'Conflict'
+
+TEST_RESULT_STATUS_ENABLED = {STATUS: GcpTransferJobsStatus.ENABLED}
+TEST_RESULT_STATUS_DISABLED = {STATUS: GcpTransferJobsStatus.DISABLED}
+TEST_RESULT_STATUS_DELETED = {STATUS: GcpTransferJobsStatus.DELETED}
+
 
 def _without_key(body, key):
     obj = deepcopy(body)
@@ -64,6 +75,82 @@ def _without_key(body, key):
     return obj
 
 
+def _with_name(body, job_name):
+    obj = deepcopy(body)
+    obj[NAME] = job_name
+    return obj
+
+
+class GCPRequestMock:
+
+    status = TEST_HTTP_ERR_CODE
+
+
+class TestGCPTransferServiceHookWithPassedName(unittest.TestCase):
+
+    def setUp(self):
+        with mock.patch(
+            'airflow.providers.google.common.hooks.base_google.GoogleBaseHook.__init__',
+            new=mock_base_gcp_hook_no_default_project_id,
+        ):
+            self.gct_hook = CloudDataTransferServiceHook(gcp_conn_id='test')
+
+    @mock.patch(
+        'airflow.providers.google.cloud.hooks.cloud_storage_transfer_service'
+        '.CloudDataTransferServiceHook.enable_transfer_job'
+    )
+    @mock.patch(
+        'airflow.providers.google.cloud.hooks.cloud_storage_transfer_service'
+        '.CloudDataTransferServiceHook.get_transfer_job'
+    )
+    @mock.patch(
+        'airflow.providers.google.common.hooks.base_google.GoogleBaseHook.project_id',
+        new_callable=PropertyMock,
+        return_value=None
+    )
+    @mock.patch(
+        'airflow.providers.google.cloud.hooks.cloud_storage_transfer_service'
+        '.CloudDataTransferServiceHook.get_conn'
+    )
+    # pylint: disable=unused-argument
+    def test_pass_name_on_create_job(self,
+                                     get_conn: MagicMock,
+                                     project_id: PropertyMock,
+                                     get_transfer_job: MagicMock,
+                                     enable_transfer_job: MagicMock
+                                     ):
+        body = _with_name(TEST_BODY, TEST_CLEAR_JOB_NAME)
+        get_conn.side_effect \
+            = HttpError(GCPRequestMock(), TEST_HTTP_ERR_CONTENT)
+
+        with self.assertRaises(HttpError):
+
+            # check status DELETED generates new job name
+            get_transfer_job.return_value = TEST_RESULT_STATUS_DELETED
+            self.gct_hook.create_transfer_job(body=body)
+
+        # check status DISABLED changes to status ENABLED
+        get_transfer_job.return_value = TEST_RESULT_STATUS_DISABLED
+        enable_transfer_job.return_value = TEST_RESULT_STATUS_ENABLED
+
+        res = self.gct_hook.create_transfer_job(body=body)
+        self.assertEqual(res, TEST_RESULT_STATUS_ENABLED)
+
+
+class TestJobNames(unittest.TestCase):
+
+    def setUp(self) -> None:
+        self.re_suffix = re.compile("^[0-9]{10}$")
+
+    def test_new_suffix(self):
+        for job_name in ["jobNames/new_job",
+                         "jobNames/new_job_h",
+                         "jobNames/newJob"]:
+            self.assertIsNotNone(
+                self.re_suffix.match(gen_job_name(job_name).split("_")[-1])
+            )
+
+
 class TestGCPTransferServiceHookWithPassedProjectId(unittest.TestCase):
     def setUp(self):
         with mock.patch(
@@ -652,7 +739,8 @@ def test_create_transfer_job(self, get_conn, mock_project_id):
             self.gct_hook.create_transfer_job(body=_without_key(TEST_BODY, PROJECT_ID))
 
         self.assertEqual(
-            'The project id must be passed either as `projectId` key in `body` parameter or as project_id '
+            'The project id must be passed either as `projectId` key in `body` '
+            'parameter or as project_id '
             'extra in GCP connection definition. Both are not set!',
             str(e.exception),
         )