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

vincbeck 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 fd5686c8320 Add timeout and exponential backoff to Tableau refresh 
wait (#69479)
fd5686c8320 is described below

commit fd5686c83200918de9183eb6b38873dc0dcbf018
Author: Jaya Haryono Manik <[email protected]>
AuthorDate: Tue Jul 7 01:33:00 2026 +0700

    Add timeout and exponential backoff to Tableau refresh wait (#69479)
---
 providers/tableau/docs/operators.rst               |   3 +
 .../src/airflow/providers/tableau/hooks/tableau.py |  36 ++++++-
 .../airflow/providers/tableau/operators/tableau.py |  16 +++
 .../tests/unit/tableau/hooks/test_tableau.py       | 115 ++++++++++++++++++++-
 .../tests/unit/tableau/operators/test_tableau.py   |  55 ++++++++--
 5 files changed, 213 insertions(+), 12 deletions(-)

diff --git a/providers/tableau/docs/operators.rst 
b/providers/tableau/docs/operators.rst
index f4aac22e91a..9bcc830b761 100644
--- a/providers/tableau/docs/operators.rst
+++ b/providers/tableau/docs/operators.rst
@@ -36,6 +36,9 @@ Using the Operator
 | **site_id**: The id of the site where the workbook belongs to. **str** - 
Default: **None**
 | **blocking_refresh**: By default the extract refresh will be blocking means 
it will wait until it has finished. **bool** - Default: **True**
 | **check_interval**: time in seconds that the job should wait in between each 
instance state checks until operation is completed. **float** - Default: **20**
+| **timeout**: maximum total time in seconds to wait for a blocking refresh 
before giving up with a ``TimeoutError``. **float** - Default: **None** (wait 
indefinitely)
+| **exponential_backoff**: grow the wait between status checks by 50% each 
time, starting from ``check_interval``, instead of staying fixed. **bool** - 
Default: **False**
+| **max_check_interval**: maximum interval in seconds between two consecutive 
status checks when ``exponential_backoff`` is enabled. **float** - Default: 
**None** (uncapped)
 | **tableau_conn_id**: The credentials to authenticate to the Tableau Server. 
**str** - Default: **tableau_default**
 |
 |
diff --git a/providers/tableau/src/airflow/providers/tableau/hooks/tableau.py 
b/providers/tableau/src/airflow/providers/tableau/hooks/tableau.py
index 9aa994da9ec..b603d338fad 100644
--- a/providers/tableau/src/airflow/providers/tableau/hooks/tableau.py
+++ b/providers/tableau/src/airflow/providers/tableau/hooks/tableau.py
@@ -190,7 +190,15 @@ class TableauHook(BaseHook):
         """
         return 
TableauJobFinishCode(int(self.server.jobs.get_by_id(job_id).finish_code))
 
-    def wait_for_state(self, job_id: str, target_state: TableauJobFinishCode, 
check_interval: float) -> bool:
+    def wait_for_state(
+        self,
+        job_id: str,
+        target_state: TableauJobFinishCode,
+        check_interval: float,
+        timeout: float | None = None,
+        exponential_backoff: bool = False,
+        max_check_interval: float | None = None,
+    ) -> bool:
         """
         Wait until the current state of a defined Tableau Job is target_state 
or different from PENDING.
 
@@ -198,12 +206,30 @@ class TableauHook(BaseHook):
         :param target_state: Enum that describe the Tableau job's target state
         :param check_interval: time in seconds that the job should wait in
             between each instance state checks until operation is completed
+        :param timeout: maximum total time in seconds to keep polling the job 
before giving up.
+            ``None`` (the default) waits indefinitely until the job leaves the 
PENDING state.
+            This is a soft bound: an in-flight wait between checks is not 
interrupted, so a large
+            ``check_interval`` may overshoot ``timeout`` by up to one interval.
+        :param exponential_backoff: when ``True`` the wait between checks 
grows by 50% each time,
+            starting from ``check_interval``, instead of staying fixed.
+        :param max_check_interval: maximum interval in seconds between two 
consecutive checks
+            when ``exponential_backoff`` is enabled. ``None`` leaves the 
growth uncapped.
         :return: return True if the job is equal to the target_status, False 
otherwise.
+        :raises TimeoutError: if ``timeout`` elapses while the job is still 
PENDING.
         """
-        finish_code = self.get_job_status(job_id=job_id)
-        while finish_code == TableauJobFinishCode.PENDING and finish_code != 
target_state:
-            self.log.info("job state: %s", finish_code)
-            time.sleep(check_interval)
+        start = time.monotonic()
+        current_interval = check_interval
+        while True:
             finish_code = self.get_job_status(job_id=job_id)
+            if finish_code != TableauJobFinishCode.PENDING or finish_code == 
target_state:
+                break
+            if timeout is not None and time.monotonic() - start >= timeout:
+                raise TimeoutError(f"Tableau job {job_id} is still PENDING 
after {timeout} seconds.")
+            self.log.info("job state: %s; checking again in %s seconds", 
finish_code, current_interval)
+            time.sleep(current_interval)
+            if exponential_backoff:
+                current_interval *= 1.5
+                if max_check_interval is not None:
+                    current_interval = min(current_interval, 
max_check_interval)
 
         return finish_code == target_state
diff --git 
a/providers/tableau/src/airflow/providers/tableau/operators/tableau.py 
b/providers/tableau/src/airflow/providers/tableau/operators/tableau.py
index 9cb58657645..f7158f95e42 100644
--- a/providers/tableau/src/airflow/providers/tableau/operators/tableau.py
+++ b/providers/tableau/src/airflow/providers/tableau/operators/tableau.py
@@ -66,6 +66,13 @@ class TableauOperator(BaseOperator):
     :param blocking_refresh: By default will be blocking means it will wait 
until it has finished.
     :param check_interval: time in seconds that the job should wait in
         between each instance state checks until operation is completed
+    :param timeout: maximum total time in seconds to wait for a blocking 
refresh to finish before
+        giving up with a ``TimeoutError``. ``None`` (the default) waits 
indefinitely until the job
+        leaves the PENDING state.
+    :param exponential_backoff: when ``True`` the wait between status checks 
grows by 50% each
+        time, starting from ``check_interval``, instead of staying fixed.
+    :param max_check_interval: maximum interval in seconds between two 
consecutive status checks
+        when ``exponential_backoff`` is enabled. ``None`` leaves the growth 
uncapped.
     :param incremental_refresh: Whether to perform an incremental refresh 
instead of a full refresh.
         Only applies to datasource and workbook refresh operations. Defaults 
to False (full refresh).
     :param tableau_conn_id: The :ref:`Tableau Connection id 
<howto/connection:tableau>`
@@ -87,6 +94,9 @@ class TableauOperator(BaseOperator):
         site_id: str | None = None,
         blocking_refresh: bool = True,
         check_interval: float = 20,
+        timeout: float | None = None,
+        exponential_backoff: bool = False,
+        max_check_interval: float | None = None,
         incremental_refresh: bool = False,
         tableau_conn_id: str = "tableau_default",
         **kwargs,
@@ -97,6 +107,9 @@ class TableauOperator(BaseOperator):
         self.find = find
         self.match_with = match_with
         self.check_interval = check_interval
+        self.timeout = timeout
+        self.exponential_backoff = exponential_backoff
+        self.max_check_interval = max_check_interval
         self.site_id = site_id
         self.blocking_refresh = blocking_refresh
         self.incremental_refresh = incremental_refresh
@@ -163,6 +176,9 @@ class TableauOperator(BaseOperator):
                         job_id=job_id,
                         check_interval=self.check_interval,
                         target_state=TableauJobFinishCode.SUCCESS,
+                        timeout=self.timeout,
+                        exponential_backoff=self.exponential_backoff,
+                        max_check_interval=self.max_check_interval,
                     ):
                         raise TableauJobFailedException(f"The Tableau Refresh 
{self.resource} Job failed!")
 
diff --git a/providers/tableau/tests/unit/tableau/hooks/test_tableau.py 
b/providers/tableau/tests/unit/tableau/hooks/test_tableau.py
index 977dbf07558..3b9b084ee8d 100644
--- a/providers/tableau/tests/unit/tableau/hooks/test_tableau.py
+++ b/providers/tableau/tests/unit/tableau/hooks/test_tableau.py
@@ -18,7 +18,7 @@ from __future__ import annotations
 
 import json
 import tempfile
-from unittest.mock import MagicMock, patch
+from unittest.mock import MagicMock, call, patch
 
 import pytest
 
@@ -460,3 +460,116 @@ class TestTableauHook:
             assert tableau_hook.wait_for_state(
                 job_id="j1", target_state=TableauJobFinishCode.PENDING, 
check_interval=1
             )
+
+    @patch("tableauserverclient.Server")
+    def test_wait_for_state_timeout(self, mock_tableau_server):
+        """A job stuck in PENDING raises TimeoutError once the timeout 
elapses."""
+        clock = {"t": 0.0}
+        with TableauHook(tableau_conn_id="tableau_test_password") as 
tableau_hook:
+            tableau_hook.get_job_status = MagicMock(
+                name="get_job_status",
+                return_value=TableauJobFinishCode.PENDING,
+            )
+            with (
+                patch("time.monotonic", side_effect=lambda: clock["t"]),
+                patch("time.sleep", side_effect=lambda seconds: 
clock.__setitem__("t", clock["t"] + seconds)),
+            ):
+                with pytest.raises(TimeoutError, match="still PENDING after 30 
seconds"):
+                    tableau_hook.wait_for_state(
+                        job_id="j1",
+                        target_state=TableauJobFinishCode.SUCCESS,
+                        check_interval=10,
+                        timeout=30,
+                    )
+
+    @pytest.mark.parametrize(
+        ("job_status", "expected"),
+        [
+            pytest.param(
+                MagicMock(
+                    name="get_job_status",
+                    side_effect=[TableauJobFinishCode.PENDING, 
TableauJobFinishCode.SUCCESS],
+                ),
+                True,
+                id="finished-at-deadline",
+            ),
+            pytest.param(
+                MagicMock(name="get_job_status", 
return_value=TableauJobFinishCode.PENDING),
+                "raises",
+                id="still-pending",
+            ),
+        ],
+    )
+    @patch("tableauserverclient.Server")
+    def test_wait_for_state_uses_last_poll_result_on_timeout(self, 
mock_tableau_server, job_status, expected):
+        """A job that finishes on the poll taken at the deadline succeeds; one 
still PENDING times out."""
+        clock = {"t": 0.0}
+        with TableauHook(tableau_conn_id="tableau_test_password") as 
tableau_hook:
+            tableau_hook.get_job_status = job_status
+            with (
+                patch("time.monotonic", side_effect=lambda: clock["t"]),
+                patch("time.sleep", side_effect=lambda seconds: 
clock.__setitem__("t", clock["t"] + seconds)),
+            ):
+                if expected == "raises":
+                    with pytest.raises(TimeoutError):
+                        tableau_hook.wait_for_state(
+                            job_id="j1",
+                            target_state=TableauJobFinishCode.SUCCESS,
+                            check_interval=5,
+                            timeout=5,
+                        )
+                else:
+                    assert tableau_hook.wait_for_state(
+                        job_id="j1",
+                        target_state=TableauJobFinishCode.SUCCESS,
+                        check_interval=5,
+                        timeout=5,
+                    )
+
+    @patch("time.sleep", return_value=None)
+    @patch("tableauserverclient.Server")
+    def test_wait_for_state_timeout_success_before_elapsed(self, 
mock_tableau_server, sleep_mock):
+        """timeout does not interfere when the job finishes before it 
elapses."""
+        with TableauHook(tableau_conn_id="tableau_test_password") as 
tableau_hook:
+            tableau_hook.get_job_status = MagicMock(
+                name="get_job_status",
+                side_effect=[TableauJobFinishCode.PENDING, 
TableauJobFinishCode.SUCCESS],
+            )
+            assert tableau_hook.wait_for_state(
+                job_id="j1",
+                target_state=TableauJobFinishCode.SUCCESS,
+                check_interval=1,
+                timeout=300,
+            )
+
+    @pytest.mark.parametrize(
+        ("max_check_interval", "expected_intervals"),
+        [
+            pytest.param(None, [10, 15, 22.5], id="uncapped"),
+            pytest.param(12, [10, 12, 12], id="capped"),
+        ],
+    )
+    @patch("time.sleep", return_value=None)
+    @patch("tableauserverclient.Server")
+    def test_wait_for_state_exponential_backoff(
+        self, mock_tableau_server, sleep_mock, max_check_interval, 
expected_intervals
+    ):
+        """Exponential backoff grows the wait between checks by 50% each time, 
honoring the cap."""
+        with TableauHook(tableau_conn_id="tableau_test_password") as 
tableau_hook:
+            tableau_hook.get_job_status = MagicMock(
+                name="get_job_status",
+                side_effect=[
+                    TableauJobFinishCode.PENDING,
+                    TableauJobFinishCode.PENDING,
+                    TableauJobFinishCode.PENDING,
+                    TableauJobFinishCode.SUCCESS,
+                ],
+            )
+            assert tableau_hook.wait_for_state(
+                job_id="j1",
+                target_state=TableauJobFinishCode.SUCCESS,
+                check_interval=10,
+                exponential_backoff=True,
+                max_check_interval=max_check_interval,
+            )
+            assert sleep_mock.call_args_list == [call(interval) for interval 
in expected_intervals]
diff --git a/providers/tableau/tests/unit/tableau/operators/test_tableau.py 
b/providers/tableau/tests/unit/tableau/operators/test_tableau.py
index c2e9e5157fc..759a0344c01 100644
--- a/providers/tableau/tests/unit/tableau/operators/test_tableau.py
+++ b/providers/tableau/tests/unit/tableau/operators/test_tableau.py
@@ -87,7 +87,7 @@ class TestTableauOperator:
         def mock_hook_exit(exc_type, exc_val, exc_tb):
             mock_signed_in[0] = False
 
-        def mock_wait_for_state(job_id, target_state, check_interval):
+        def mock_wait_for_state(job_id, target_state, check_interval, 
**kwargs):
             if not mock_signed_in[0]:
                 raise Exception("Not signed in")
 
@@ -109,7 +109,12 @@ class TestTableauOperator:
         mock_tableau_hook.server.workbooks.refresh.assert_called_once_with(2)
         assert mock_tableau_hook.server.workbooks.refresh.return_value.id == 
job_id
         mock_tableau_hook.wait_for_state.assert_called_once_with(
-            job_id=job_id, check_interval=20, 
target_state=TableauJobFinishCode.SUCCESS
+            job_id=job_id,
+            check_interval=20,
+            target_state=TableauJobFinishCode.SUCCESS,
+            timeout=None,
+            exponential_backoff=False,
+            max_check_interval=None,
         )
 
     @patch("airflow.providers.tableau.operators.tableau.TableauHook")
@@ -152,7 +157,7 @@ class TestTableauOperator:
         def mock_hook_exit(exc_type, exc_val, exc_tb):
             mock_signed_in[0] = False
 
-        def mock_wait_for_state(job_id, target_state, check_interval):
+        def mock_wait_for_state(job_id, target_state, check_interval, 
**kwargs):
             if not mock_signed_in[0]:
                 raise Exception("Not signed in")
 
@@ -170,7 +175,12 @@ class TestTableauOperator:
         mock_tableau_hook.server.datasources.refresh.assert_called_once_with(2)
         assert mock_tableau_hook.server.datasources.refresh.return_value.id == 
job_id
         mock_tableau_hook.wait_for_state.assert_called_once_with(
-            job_id=job_id, check_interval=20, 
target_state=TableauJobFinishCode.SUCCESS
+            job_id=job_id,
+            check_interval=20,
+            target_state=TableauJobFinishCode.SUCCESS,
+            timeout=None,
+            exponential_backoff=False,
+            max_check_interval=None,
         )
 
     @patch("airflow.providers.tableau.operators.tableau.TableauHook")
@@ -372,7 +382,7 @@ class TestTableauOperator:
         def mock_hook_exit(exc_type, exc_val, exc_tb):
             mock_signed_in[0] = False
 
-        def mock_wait_for_state(job_id, target_state, check_interval):
+        def mock_wait_for_state(job_id, target_state, check_interval, 
**kwargs):
             if not mock_signed_in[0]:
                 raise Exception("Not signed in")
             return True
@@ -394,7 +404,12 @@ class TestTableauOperator:
         
mock_tableau_hook.server.datasources.refresh.assert_called_once_with(2, 
incremental=True)
         assert mock_tableau_hook.server.datasources.refresh.return_value.id == 
job_id
         mock_tableau_hook.wait_for_state.assert_called_once_with(
-            job_id=job_id, check_interval=20, 
target_state=TableauJobFinishCode.SUCCESS
+            job_id=job_id,
+            check_interval=20,
+            target_state=TableauJobFinishCode.SUCCESS,
+            timeout=None,
+            exponential_backoff=False,
+            max_check_interval=None,
         )
 
     @patch("airflow.providers.tableau.operators.tableau.TableauHook")
@@ -487,3 +502,31 @@ class TestTableauOperator:
         # Verify that refresh was called WITHOUT the incremental parameter
         mock_tableau_hook.server.datasources.refresh.assert_called_once_with(2)
         assert job_id == 
mock_tableau_hook.server.datasources.refresh.return_value.id
+
+    @patch("airflow.providers.tableau.operators.tableau.TableauHook")
+    def test_blocking_refresh_forwards_wait_for_state_options(self, 
mock_tableau_hook):
+        """timeout/exponential_backoff/max_check_interval should be forwarded 
to wait_for_state."""
+        mock_tableau_hook.return_value.__enter__ = 
Mock(return_value=mock_tableau_hook)
+        mock_tableau_hook.wait_for_state = Mock(return_value=True)
+        mock_tableau_hook.get_all = Mock(return_value=self.mock_datasources)
+
+        operator = TableauOperator(
+            find="ds_2",
+            resource="datasources",
+            check_interval=5,
+            timeout=300,
+            exponential_backoff=True,
+            max_check_interval=120,
+            **self.kwargs,
+        )
+
+        job_id = operator.execute(context={})
+
+        mock_tableau_hook.wait_for_state.assert_called_once_with(
+            job_id=job_id,
+            check_interval=5,
+            target_state=TableauJobFinishCode.SUCCESS,
+            timeout=300,
+            exponential_backoff=True,
+            max_check_interval=120,
+        )

Reply via email to