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

shahar1 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 91353550246 Honor download_num_attempts and upload_num_attempts in 
GCSTimeSpanFileTransformOperator (#72953)
91353550246 is described below

commit 91353550246cc27bd1a225bbdbe68e16b34ed538
Author: Bingqin Wang <[email protected]>
AuthorDate: Sat Oct 3 02:26:57 2026 -0500

    Honor download_num_attempts and upload_num_attempts in 
GCSTimeSpanFileTransformOperator (#72953)
---
 .../providers/google/cloud/operators/gcs.py        |  24 +++-
 .../tests/unit/google/cloud/operators/test_gcs.py  | 135 +++++++++++++++++++++
 2 files changed, 156 insertions(+), 3 deletions(-)

diff --git 
a/providers/google/src/airflow/providers/google/cloud/operators/gcs.py 
b/providers/google/src/airflow/providers/google/cloud/operators/gcs.py
index 59e077e2406..9265f23ac0a 100644
--- a/providers/google/src/airflow/providers/google/cloud/operators/gcs.py
+++ b/providers/google/src/airflow/providers/google/cloud/operators/gcs.py
@@ -20,6 +20,7 @@
 from __future__ import annotations
 
 import datetime
+import logging
 import subprocess
 import sys
 import warnings
@@ -30,6 +31,7 @@ from tempfile import NamedTemporaryFile, TemporaryDirectory
 from typing import TYPE_CHECKING
 
 import pendulum
+import tenacity
 
 from airflow.providers.google.common.hooks.base_google import 
PROVIDE_PROJECT_ID
 
@@ -985,6 +987,16 @@ class 
GCSTimeSpanFileTransformOperator(GoogleCloudBaseOperator):
             # Get storage client once (storage.Client is thread-safe for 
concurrent requests).
             client = source_hook.get_conn()
 
+            # A transient ``GoogleCloudError`` retries the whole download. 
``reraise`` keeps the
+            # original error after the last attempt, for the 
``download_continue_on_fail``
+            # handling below. The waits (2, 4, 8 s, ...) match 
``GCSHook.download``.
+            @tenacity.retry(
+                stop=tenacity.stop_after_attempt(self.download_num_attempts),
+                wait=tenacity.wait_exponential(multiplier=2, max=60),
+                retry=tenacity.retry_if_exception_type(GoogleCloudError),
+                before_sleep=tenacity.before_sleep_log(self.log, 
logging.WARNING),
+                reraise=True,
+            )
             def _download(blob_name: str):
 
                 bucket = client.bucket(bucket_name=self.source_bucket)
@@ -1065,6 +1077,14 @@ class 
GCSTimeSpanFileTransformOperator(GoogleCloudBaseOperator):
             # Get storage client once (storage.Client is thread-safe for 
concurrent requests).
             client = destination_hook.get_conn()
 
+            # Same retry policy as the downloads, for ``upload_num_attempts``.
+            @tenacity.retry(
+                stop=tenacity.stop_after_attempt(self.upload_num_attempts),
+                wait=tenacity.wait_exponential(multiplier=2, max=60),
+                retry=tenacity.retry_if_exception_type(GoogleCloudError),
+                before_sleep=tenacity.before_sleep_log(self.log, 
logging.WARNING),
+                reraise=True,
+            )
             def _upload(upload_file: Path):
 
                 bucket = client.bucket(bucket_name=self.destination_bucket)
@@ -1077,9 +1097,7 @@ class 
GCSTimeSpanFileTransformOperator(GoogleCloudBaseOperator):
 
                 blob = bucket.blob(blob_name=upload_file_name, 
chunk_size=self.chunk_size)
 
-                blob.upload_from_filename(
-                    filename=str(upload_file),
-                )
+                blob.upload_from_filename(filename=str(upload_file))
 
                 return upload_file_name
 
diff --git a/providers/google/tests/unit/google/cloud/operators/test_gcs.py 
b/providers/google/tests/unit/google/cloud/operators/test_gcs.py
index 4d53d254e49..fcd17462ead 100644
--- a/providers/google/tests/unit/google/cloud/operators/test_gcs.py
+++ b/providers/google/tests/unit/google/cloud/operators/test_gcs.py
@@ -521,6 +521,16 @@ class TestGCSTimeSpanFileTransformOperator:
         mock_blob = mock_bucket.blob.return_value
         return mock_client, mock_bucket, mock_blob
 
+    @staticmethod
+    def _setup_transform_process(mock_subprocess):
+        mock_proc = mock.MagicMock()
+        mock_proc.returncode = 0
+        mock_proc.stdout.readline = lambda: b""
+        mock_proc.wait.return_value = None
+        mock_subprocess.Popen.return_value.__enter__.return_value = mock_proc
+        mock_subprocess.PIPE = "pipe"
+        mock_subprocess.STDOUT = "stdout"
+
     
@mock.patch("airflow.providers.google.cloud.operators.gcs.TemporaryDirectory")
     @mock.patch("airflow.providers.google.cloud.operators.gcs.subprocess")
     @mock.patch("airflow.providers.google.cloud.operators.gcs.GCSHook")
@@ -1151,6 +1161,131 @@ class TestGCSTimeSpanFileTransformOperator:
             op.execute(context=context)
         mock_blob.download_to_filename.assert_not_called()
 
+    @pytest.mark.parametrize(
+        ("num_attempts", "outcomes", "succeeds", "expected_sleeps"),
+        [
+            (1, [GoogleCloudError("fail")], False, []),
+            (2, [GoogleCloudError("fail"), GoogleCloudError("fail")], False, 
[2]),
+            (3, [GoogleCloudError("fail"), None], True, [2]),
+        ],
+    )
+    @mock.patch("tenacity.nap.time.sleep")
+    
@mock.patch("airflow.providers.google.cloud.operators.gcs.TemporaryDirectory")
+    @mock.patch("airflow.providers.google.cloud.operators.gcs.subprocess")
+    @mock.patch("airflow.providers.google.cloud.operators.gcs.GCSHook")
+    def test_download_honors_download_num_attempts(
+        self,
+        mock_hook,
+        mock_subprocess,
+        mock_tempdir,
+        mock_sleep,
+        num_attempts,
+        outcomes,
+        succeeds,
+        expected_sleeps,
+    ):
+        timespan_start = datetime(2015, 2, 1, tzinfo=timezone.utc)
+        context = {
+            "logical_date": timespan_start,
+            "data_interval_start": timespan_start,
+            "data_interval_end": timespan_start + timedelta(hours=1),
+            "ti": mock.Mock(),
+            "task": mock.MagicMock(),
+        }
+        mock_tempdir.return_value.__enter__.side_effect = ["source", 
"destination"]
+        mock_hook.return_value.list_by_timespan.return_value = ["file1"]
+        _, _, mock_blob = self._setup_gcs_client_chain(mock_hook)
+        mock_blob.download_to_filename.side_effect = outcomes
+        self._setup_transform_process(mock_subprocess)
+
+        op = GCSTimeSpanFileTransformOperator(
+            task_id=TASK_ID,
+            source_bucket="bucket",
+            source_prefix="prefix",
+            source_gcp_conn_id="",
+            destination_bucket="dest",
+            destination_prefix="dest",
+            destination_gcp_conn_id="",
+            transform_script="script.py",
+            download_num_attempts=num_attempts,
+        )
+
+        with (
+            mock.patch.object(Path, "glob") as path_glob,
+            mock.patch.object(Path, "is_file", return_value=True),
+        ):
+            path_glob.return_value.__iter__.return_value = []
+            if succeeds:
+                op.execute(context=context)
+            else:
+                with pytest.raises(GoogleCloudError):
+                    op.execute(context=context)
+
+        assert mock_blob.download_to_filename.call_count == len(outcomes)
+        assert [call.args[0] for call in mock_sleep.call_args_list] == 
expected_sleeps
+
+    @pytest.mark.parametrize(
+        ("num_attempts", "outcomes", "succeeds", "expected_sleeps"),
+        [
+            (1, [GoogleCloudError("fail")], False, []),
+            (2, [GoogleCloudError("fail"), None], True, [2]),
+        ],
+    )
+    @mock.patch("tenacity.nap.time.sleep")
+    
@mock.patch("airflow.providers.google.cloud.operators.gcs.TemporaryDirectory")
+    @mock.patch("airflow.providers.google.cloud.operators.gcs.subprocess")
+    @mock.patch("airflow.providers.google.cloud.operators.gcs.GCSHook")
+    def test_upload_honors_upload_num_attempts(
+        self,
+        mock_hook,
+        mock_subprocess,
+        mock_tempdir,
+        mock_sleep,
+        num_attempts,
+        outcomes,
+        succeeds,
+        expected_sleeps,
+    ):
+        timespan_start = datetime(2015, 2, 1, tzinfo=timezone.utc)
+        context = {
+            "logical_date": timespan_start,
+            "data_interval_start": timespan_start,
+            "data_interval_end": timespan_start + timedelta(hours=1),
+            "ti": mock.Mock(),
+            "task": mock.MagicMock(),
+        }
+        mock_tempdir.return_value.__enter__.side_effect = ["source", 
"destination"]
+        mock_hook.return_value.list_by_timespan.return_value = []
+        _, _, mock_blob = self._setup_gcs_client_chain(mock_hook)
+        mock_blob.upload_from_filename.side_effect = outcomes
+        self._setup_transform_process(mock_subprocess)
+
+        op = GCSTimeSpanFileTransformOperator(
+            task_id=TASK_ID,
+            source_bucket="bucket",
+            source_prefix="prefix",
+            source_gcp_conn_id="",
+            destination_bucket="dest",
+            destination_prefix="dest",
+            destination_gcp_conn_id="",
+            transform_script="script.py",
+            upload_num_attempts=num_attempts,
+        )
+
+        with (
+            mock.patch.object(Path, "glob") as path_glob,
+            mock.patch.object(Path, "is_file", return_value=True),
+        ):
+            path_glob.return_value.__iter__.return_value = 
[Path("destination/file1")]
+            if succeeds:
+                assert op.execute(context=context) == ["dest/file1"]
+            else:
+                with pytest.raises(GoogleCloudError):
+                    op.execute(context=context)
+
+        assert mock_blob.upload_from_filename.call_count == len(outcomes)
+        assert [call.args[0] for call in mock_sleep.call_args_list] == 
expected_sleeps
+
 
 class TestGCSDeleteBucketOperator:
     @mock.patch("airflow.providers.google.cloud.operators.gcs.GCSHook")

Reply via email to