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

eladkal 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 069a973de5b Fix Databricks hook dropping tasks beyond the first page 
of a run (#72304)
069a973de5b is described below

commit 069a973de5b3ca6be2da70ce11093282a861634a
Author: Noritaka Sekiyama <[email protected]>
AuthorDate: Thu Sep 10 05:44:32 2026 +0900

    Fix Databricks hook dropping tasks beyond the first page of a run (#72304)
    
    The Jobs API 2.2 returns at most 100 entries of a run's tasks and
    job_clusters per page and hands back a token for the rest, but get_run
    read only the first page. Callers that inspect the task list, such as the
    failed-task error extraction behind a task's failure message, therefore
    saw an arbitrary subset of it: the entries do not come back in the order
    they were declared, so which ones went missing was not predictable.
---
 .../providers/databricks/hooks/databricks.py       | 57 ++++++++++++----------
 .../tests/unit/databricks/hooks/test_databricks.py | 48 ++++++++++++++++++
 2 files changed, 80 insertions(+), 25 deletions(-)

diff --git 
a/providers/databricks/src/airflow/providers/databricks/hooks/databricks.py 
b/providers/databricks/src/airflow/providers/databricks/hooks/databricks.py
index 2223b5a5a4b..f21882a5763 100644
--- a/providers/databricks/src/airflow/providers/databricks/hooks/databricks.py
+++ b/providers/databricks/src/airflow/providers/databricks/hooks/databricks.py
@@ -71,6 +71,19 @@ SPARK_VERSIONS_ENDPOINT = ("GET", 
"2.1/clusters/spark-versions")
 SQL_STATEMENTS_ENDPOINT = "2.0/sql/statements"
 SQL_WAREHOUSES_ENDPOINT = "2.0/sql/warehouses"
 
+# ``2.2/jobs/runs/get`` returns at most 100 entries of these nested arrays per 
page and hands back a
+# ``next_page_token`` for the rest, while repeating every other field on each 
page. The order of the
+# entries is not the order they were declared in, so a truncated response 
drops an arbitrary subset.
+PAGINATED_RUN_FIELDS = ("tasks", "job_clusters")
+
+
+def _merge_run_page(run: dict[str, Any], page: dict[str, Any]) -> None:
+    """Append the paginated arrays of ``page`` to those already collected in 
``run``."""
+    for field in PAGINATED_RUN_FIELDS:
+        entries = page.get(field)
+        if entries:
+            run[field] = [*run.get(field, []), *entries]
+
 
 class RunLifeCycleState(Enum):
     """
@@ -593,45 +606,39 @@ class DatabricksHook(BaseDatabricksHook):
         :param run_id: id of the run
         :return: A list of tasks
         """
-        has_more = True
-        all_tasks = []
-        page_token = ""
-        json: dict[str, Any] = {"run_id": run_id}
-
-        while has_more:
-            if page_token:
-                json = {**json, "page_token": page_token}
-            response = self._do_api_call(GET_RUN_ENDPOINT, json)
-            tasks = response.get("tasks", [])
-            all_tasks += tasks
-            if "next_page_token" in response:
-                page_token = response["next_page_token"]
-            else:
-                has_more = False
-
-        return all_tasks
+        return self.get_run(run_id).get("tasks", [])
 
     def get_run(self, run_id: int) -> dict[str, Any]:
         """
         Retrieve run information.
 
         :param run_id: id of the run
-        :return: state of the run
+        :return: state of the run, with every page of the paginated arrays 
collected
         """
-        json = {"run_id": run_id}
-        response = self._do_api_call(GET_RUN_ENDPOINT, json)
-        return response
+        json: dict[str, Any] = {"run_id": run_id}
+        run = self._do_api_call(GET_RUN_ENDPOINT, json)
+        page_token = run.pop("next_page_token", None)
+        while page_token:
+            page = self._do_api_call(GET_RUN_ENDPOINT, {**json, "page_token": 
page_token})
+            _merge_run_page(run, page)
+            page_token = page.get("next_page_token")
+        return run
 
     async def a_get_run(self, run_id: int) -> dict[str, Any]:
         """
         Async version of `get_run`.
 
         :param run_id: id of the run
-        :return: state of the run
+        :return: state of the run, with every page of the paginated arrays 
collected
         """
-        json = {"run_id": run_id}
-        response = await self._a_do_api_call(GET_RUN_ENDPOINT, json)
-        return response
+        json: dict[str, Any] = {"run_id": run_id}
+        run = await self._a_do_api_call(GET_RUN_ENDPOINT, json)
+        page_token = run.pop("next_page_token", None)
+        while page_token:
+            page = await self._a_do_api_call(GET_RUN_ENDPOINT, {**json, 
"page_token": page_token})
+            _merge_run_page(run, page)
+            page_token = page.get("next_page_token")
+        return run
 
     def get_run_state_str(self, run_id: int) -> str:
         """
diff --git 
a/providers/databricks/tests/unit/databricks/hooks/test_databricks.py 
b/providers/databricks/tests/unit/databricks/hooks/test_databricks.py
index c1959cd2359..38d0bd1da10 100644
--- a/providers/databricks/tests/unit/databricks/hooks/test_databricks.py
+++ b/providers/databricks/tests/unit/databricks/hooks/test_databricks.py
@@ -750,6 +750,36 @@ class TestDatabricksHook:
         assert len(tasks) == 2
         assert tasks == GET_RUN_RESPONSE["tasks"] * 2
 
+    @mock.patch("airflow.providers.databricks.hooks.databricks_base.requests")
+    def test_get_run_collects_every_page(self, mock_requests):
+        mock_requests.codes.ok = 200
+        mock_requests.get.side_effect = [
+            create_successful_response_mock(
+                {
+                    **GET_RUN_RESPONSE,
+                    "tasks": [{"task_key": "first"}],
+                    "job_clusters": [{"job_cluster_key": "jc_a"}],
+                    "next_page_token": "PAGETOKEN",
+                }
+            ),
+            create_successful_response_mock(
+                {
+                    **GET_RUN_RESPONSE,
+                    "tasks": [{"task_key": "second"}],
+                    "job_clusters": [{"job_cluster_key": "jc_b"}],
+                }
+            ),
+        ]
+
+        run = self.hook.get_run(RUN_ID)
+
+        assert mock_requests.get.call_count == 2
+        assert mock_requests.method_calls[1][2]["params"] == {"run_id": 
RUN_ID, "page_token": "PAGETOKEN"}
+        assert run["tasks"] == [{"task_key": "first"}, {"task_key": "second"}]
+        assert run["job_clusters"] == [{"job_cluster_key": "jc_a"}, 
{"job_cluster_key": "jc_b"}]
+        assert "next_page_token" not in run
+        assert run["state"] == GET_RUN_RESPONSE["state"]
+
     @mock.patch("airflow.providers.databricks.hooks.databricks_base.requests")
     def test_cancel_run(self, mock_requests):
         mock_requests.post.return_value.json.return_value = GET_RUN_RESPONSE
@@ -2132,6 +2162,24 @@ class TestDatabricksHookAsyncMethods:
             timeout=self.hook.timeout_seconds,
         )
 
+    @pytest.mark.asyncio
+    
@mock.patch("airflow.providers.databricks.hooks.databricks_base.aiohttp.ClientSession.get")
+    async def test_a_get_run_collects_every_page(self, mock_get):
+        mock_get.return_value.__aenter__.return_value.json = AsyncMock(
+            side_effect=[
+                {**GET_RUN_RESPONSE, "tasks": [{"task_key": "first"}], 
"next_page_token": "PAGETOKEN"},
+                {**GET_RUN_RESPONSE, "tasks": [{"task_key": "second"}]},
+            ]
+        )
+
+        async with self.hook:
+            run = await self.hook.a_get_run(RUN_ID)
+
+        assert mock_get.call_count == 2
+        assert mock_get.call_args_list[1].kwargs["json"] == {"run_id": RUN_ID, 
"page_token": "PAGETOKEN"}
+        assert run["tasks"] == [{"task_key": "first"}, {"task_key": "second"}]
+        assert "next_page_token" not in run
+
     @pytest.mark.asyncio
     
@mock.patch("airflow.providers.databricks.hooks.databricks_base.aiohttp.ClientSession.get")
     async def test_get_cluster_state(self, mock_get):

Reply via email to