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

dabla 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 77cf38b1bc4 Keep MSGraph pagination offset across paginated pages 
(#71986)
77cf38b1bc4 is described below

commit 77cf38b1bc47376d588cac8aaf765f2d93f5494a
Author: PoAn Yang <[email protected]>
AuthorDate: Wed Sep 9 00:53:35 2026 +0900

    Keep MSGraph pagination offset across paginated pages (#71986)
    
    Signed-off-by: PoAn Yang <[email protected]>
---
 .../providers/microsoft/azure/hooks/msgraph.py     | 11 ++++--
 .../providers/microsoft/azure/operators/msgraph.py | 10 +++++-
 .../unit/microsoft/azure/hooks/test_msgraph.py     | 38 +++++++++++++++++++++
 .../unit/microsoft/azure/operators/test_msgraph.py | 39 ++++++++++++++++++++--
 .../unit/microsoft/azure/resources/messages.json   |  2 +-
 .../microsoft/azure/resources/second_messages.json |  1 +
 .../{next_messages.json => third_messages.json}    |  2 +-
 7 files changed, 95 insertions(+), 8 deletions(-)

diff --git 
a/providers/microsoft/azure/src/airflow/providers/microsoft/azure/hooks/msgraph.py
 
b/providers/microsoft/azure/src/airflow/providers/microsoft/azure/hooks/msgraph.py
index b81f1aacba5..0a9cca43d0d 100644
--- 
a/providers/microsoft/azure/src/airflow/providers/microsoft/azure/hooks/msgraph.py
+++ 
b/providers/microsoft/azure/src/airflow/providers/microsoft/azure/hooks/msgraph.py
@@ -576,6 +576,13 @@ class KiotaRequestAdapterHook(BaseHook):
         query_parameters: dict[str, Any] | None = None,
         responses: Callable[[], list[dict[str, Any]] | None] = lambda: [],
     ) -> tuple[Any, dict[str, Any] | None]:
+        """
+        Resolve the url and query parameters of the page following 
``response``.
+
+        The ``$skip`` offset is derived from ``query_parameters`` rather than 
from ``responses``:
+        callers accumulate whatever their own callbacks produced, so the 
entries are not guaranteed
+        to be the raw pages this offset would have to be counted from.
+        """
         if isinstance(response, dict):
             odata_count = response.get("@odata.count")
             if odata_count and query_parameters:
@@ -583,9 +590,7 @@ class KiotaRequestAdapterHook(BaseHook):
 
                 if top and odata_count:
                     if len(response.get("value", [])) == top:
-                        results = responses()
-                        skip = sum([len(result["value"]) for result in 
results]) + top if results else top  # type: ignore
-                        query_parameters["$skip"] = skip
+                        query_parameters["$skip"] = 
(query_parameters.get("$skip") or 0) + top
                         return url, query_parameters
             return response.get("@odata.nextLink"), query_parameters
         return None, query_parameters
diff --git 
a/providers/microsoft/azure/src/airflow/providers/microsoft/azure/operators/msgraph.py
 
b/providers/microsoft/azure/src/airflow/providers/microsoft/azure/operators/msgraph.py
index 049c7004477..e60dd36bdc4 100644
--- 
a/providers/microsoft/azure/src/airflow/providers/microsoft/azure/operators/msgraph.py
+++ 
b/providers/microsoft/azure/src/airflow/providers/microsoft/azure/operators/msgraph.py
@@ -187,14 +187,22 @@ class MSGraphAsyncOperator(BaseOperator):
         self,
         context: Context,
         event: dict[Any, Any] | None = None,
+        query_parameters: dict[str, Any] | None = None,
     ) -> Any:
         """
         Execute callback when MSGraphTrigger finishes execution.
 
         This method gets executed automatically when MSGraphTrigger completes 
its execution.
+
+        :param query_parameters: The query parameters the completed page was 
requested with, passed
+            back by :meth:`trigger_next_link`.  The operator is rebuilt from 
the serialized Dag on
+            every page, so without them ``self.query_parameters`` would still 
describe the first page.
         """
         self.log.debug("context: %s", context)
 
+        if query_parameters is not None:
+            self.query_parameters = query_parameters
+
         if event:
             self.log.debug("%s completed with %s: %s", self.task_id, 
event.get("status"), event)
 
@@ -315,7 +323,6 @@ class MSGraphAsyncOperator(BaseOperator):
             response=response,
             url=operator.url,
             query_parameters=operator.query_parameters,
-            responses=lambda: operator.pull_xcom(context),
         )
 
     def trigger_next_link(self, response, method_name: str, context: Context) 
-> None:
@@ -355,4 +362,5 @@ class MSGraphAsyncOperator(BaseOperator):
                         pagination_link=True,
                     ),
                     method_name=method_name,
+                    kwargs={"query_parameters": query_parameters},
                 )
diff --git 
a/providers/microsoft/azure/tests/unit/microsoft/azure/hooks/test_msgraph.py 
b/providers/microsoft/azure/tests/unit/microsoft/azure/hooks/test_msgraph.py
index 0a8dead772b..c2fe69ff9f3 100644
--- a/providers/microsoft/azure/tests/unit/microsoft/azure/hooks/test_msgraph.py
+++ b/providers/microsoft/azure/tests/unit/microsoft/azure/hooks/test_msgraph.py
@@ -442,6 +442,44 @@ class TestKiotaRequestAdapterHook:
             assert isinstance(actual, list)
             assert actual == [users, next_users]
 
+    @pytest.mark.asyncio
+    @pytest.mark.parametrize(
+        ("query_parameters", "expected_skips"),
+        [
+            pytest.param(
+                {"$top": 12, "$count": True},
+                ["", "&%24skip=12", "&%24skip=24"],
+                id="from_the_start",
+            ),
+            pytest.param(
+                {"$top": 12, "$count": True, "$skip": 100},
+                ["&%24skip=100", "&%24skip=112", "&%24skip=124"],
+                id="from_a_user_supplied_offset",
+            ),
+        ],
+    )
+    async def test_paginated_run_advances_the_skip_offset_by_a_single_page(
+        self, query_parameters, expected_skips
+    ):
+        messages = load_json_from_resources(dirname(__file__), "..", 
"resources", "messages.json")
+        second_messages = load_json_from_resources(
+            dirname(__file__), "..", "resources", "second_messages.json"
+        )
+        third_messages = load_json_from_resources(dirname(__file__), "..", 
"resources", "third_messages.json")
+        response = mock_json_response(200, messages, second_messages, 
third_messages)
+
+        with patch_hook_and_request_adapter(response) as mocks:
+            mock_get_http_response = mocks[-1]
+            hook = KiotaRequestAdapterHook(conn_id="msgraph_api")
+
+            # paginated_run mutates the query parameters it is given, so hand 
it a copy rather than
+            # the dict pytest built once at collection time.
+            await hook.paginated_run(url="users/messages", 
query_parameters=dict(query_parameters))
+
+        urls = [call.args[0].url for call in 
mock_get_http_response.call_args_list]
+
+        assert urls == [f"users/messages?%24top=12&%24count=true{skip}" for 
skip in expected_skips]
+
     @pytest.mark.asyncio
     async def test_paginated_run_refuses_cross_host_next_link(self):
         first_page = {
diff --git 
a/providers/microsoft/azure/tests/unit/microsoft/azure/operators/test_msgraph.py
 
b/providers/microsoft/azure/tests/unit/microsoft/azure/operators/test_msgraph.py
index cdb1715b7e1..e2d14a67ff9 100644
--- 
a/providers/microsoft/azure/tests/unit/microsoft/azure/operators/test_msgraph.py
+++ 
b/providers/microsoft/azure/tests/unit/microsoft/azure/operators/test_msgraph.py
@@ -356,10 +356,44 @@ class TestMSGraphAsyncOperator:
         assert trigger.url == 
"users/{user_id}/mailFolders/{mailFolder_id}/messages"
         assert trigger.path_parameters == path_parameters
 
+    def 
test_execute_complete_advances_the_skip_offset_it_was_resumed_with(self):
+        # The operator is rebuilt from the serialized Dag on every page, so 
its own query parameters
+        # describe the first request; only the resume kwargs know where the 
completed page came from.
+        operator = MSGraphAsyncOperator(
+            task_id="messages",
+            conn_id="msgraph_api",
+            url="users/messages",
+            query_parameters={"$top": 12, "$count": True},
+        )
+        context = mock_context(task=operator)
+        second_messages = load_json_from_resources(
+            dirname(__file__), "..", "resources", "second_messages.json"
+        )
+        event = {"status": "success", "type": "builtins.dict", "response": 
json.dumps(second_messages)}
+
+        with mock.patch.object(operator, "defer") as mock_defer:
+            operator.execute_complete(
+                context=context,
+                event=event,
+                query_parameters={"$top": 12, "$count": True, "$skip": 12},
+            )
+
+        assert mock_defer.call_args.kwargs["trigger"].query_parameters == {
+            "$top": 12,
+            "$count": True,
+            "$skip": 24,
+        }
+        assert mock_defer.call_args.kwargs["kwargs"] == {
+            "query_parameters": {"$top": 12, "$count": True, "$skip": 24}
+        }
+
     def test_skip_pagination_expands_the_url_template_on_every_page(self):
         messages = load_json_from_resources(dirname(__file__), "..", 
"resources", "messages.json")
-        next_messages = load_json_from_resources(dirname(__file__), "..", 
"resources", "next_messages.json")
-        response = mock_json_response(200, messages, next_messages)
+        second_messages = load_json_from_resources(
+            dirname(__file__), "..", "resources", "second_messages.json"
+        )
+        third_messages = load_json_from_resources(dirname(__file__), "..", 
"resources", "third_messages.json")
+        response = mock_json_response(200, messages, second_messages, 
third_messages)
 
         with patch_hook_and_request_adapter(response) as (*_, 
mock_get_http_response):
             operator = MSGraphAsyncOperator(
@@ -378,6 +412,7 @@ class TestMSGraphAsyncOperator:
         assert urls == [
             
"users/48d31887-5fad-4d73-a9f5-3c356e68a038/mailFolders/inbox/messages?%24top=12&%24count=true",
             
"users/48d31887-5fad-4d73-a9f5-3c356e68a038/mailFolders/inbox/messages?%24top=12&%24count=true&%24skip=12",
+            
"users/48d31887-5fad-4d73-a9f5-3c356e68a038/mailFolders/inbox/messages?%24top=12&%24count=true&%24skip=24",
         ]
 
     def test_pagination_issues_every_page_with_the_configured_request(self):
diff --git 
a/providers/microsoft/azure/tests/unit/microsoft/azure/resources/messages.json 
b/providers/microsoft/azure/tests/unit/microsoft/azure/resources/messages.json
index e8994c021c9..c45e0457d99 100644
--- 
a/providers/microsoft/azure/tests/unit/microsoft/azure/resources/messages.json
+++ 
b/providers/microsoft/azure/tests/unit/microsoft/azure/resources/messages.json
@@ -1 +1 @@
-{"@odata.context": 
"https://graph.microsoft.com/v1.0/$metadata#users('48d31887-5fad-4d73-a9f5-3c356e68a038')/mailFolders('inbox')/messages",
 "@odata.count": 18, "@odata.nextLink": 
"https://graph.microsoft.com/v1.0/users('48d31887-5fad-4d73-a9f5-3c356e68a038')/mailFolders('inbox')/messages?%24top=12&%24count=true&%24skip=12",
 "value": [{"@odata.etag": "W/\"CQAAABYAAADHcgC8Hl9tRZ/hc1wEUs1TAAAwR4Hg\"", 
"id": "AAMkAGUAAAwTW09AAA=", "subject": "Weekly status report", 
"receivedDateTime": "2026 [...]
+{"@odata.context": 
"https://graph.microsoft.com/v1.0/$metadata#users('48d31887-5fad-4d73-a9f5-3c356e68a038')/mailFolders('inbox')/messages",
 "@odata.count": 30, "@odata.nextLink": 
"https://graph.microsoft.com/v1.0/users('48d31887-5fad-4d73-a9f5-3c356e68a038')/mailFolders('inbox')/messages?%24top=12&%24count=true&%24skip=12",
 "value": [{"@odata.etag": "W/\"CQAAABYAAADHcgC8Hl9tRZ/hc1wEUs1TAAAwR4Hg\"", 
"id": "AAMkAGUAAAwTW09AAA=", "subject": "Weekly status report", 
"receivedDateTime": "2026 [...]
diff --git 
a/providers/microsoft/azure/tests/unit/microsoft/azure/resources/second_messages.json
 
b/providers/microsoft/azure/tests/unit/microsoft/azure/resources/second_messages.json
new file mode 100644
index 00000000000..48c4f7f6721
--- /dev/null
+++ 
b/providers/microsoft/azure/tests/unit/microsoft/azure/resources/second_messages.json
@@ -0,0 +1 @@
+{"@odata.context": 
"https://graph.microsoft.com/v1.0/$metadata#users('48d31887-5fad-4d73-a9f5-3c356e68a038')/mailFolders('inbox')/messages",
 "@odata.count": 30, "@odata.nextLink": 
"https://graph.microsoft.com/v1.0/users('48d31887-5fad-4d73-a9f5-3c356e68a038')/mailFolders('inbox')/messages?%24top=12&%24count=true&%24skip=24",
 "value": [{"@odata.etag": "W/\"CQAAABYAAADHcgC8Hl9tRZ/hc1wEUs1TAAAwTA01\"", 
"id": "AAMkAGUAAAwUW09AAA=", "subject": "Re: Reminder: timesheet due", 
"receivedDateTime" [...]
diff --git 
a/providers/microsoft/azure/tests/unit/microsoft/azure/resources/next_messages.json
 
b/providers/microsoft/azure/tests/unit/microsoft/azure/resources/third_messages.json
similarity index 95%
rename from 
providers/microsoft/azure/tests/unit/microsoft/azure/resources/next_messages.json
rename to 
providers/microsoft/azure/tests/unit/microsoft/azure/resources/third_messages.json
index 81470210d56..e2f8bb14649 100644
--- 
a/providers/microsoft/azure/tests/unit/microsoft/azure/resources/next_messages.json
+++ 
b/providers/microsoft/azure/tests/unit/microsoft/azure/resources/third_messages.json
@@ -1 +1 @@
-{"@odata.context": 
"https://graph.microsoft.com/v1.0/$metadata#users('48d31887-5fad-4d73-a9f5-3c356e68a038')/mailFolders('inbox')/messages",
 "@odata.count": 18, "value": [{"@odata.etag": 
"W/\"CQAAABYAAADHcgC8Hl9tRZ/hc1wEUs1TAAAwSp4B\"", "id": "AAMkAGUAAAwTXCMAAA=", 
"subject": "Re: Deployment window moved", "receivedDateTime": 
"2026-08-17T09:18:47Z", "isRead": true}, {"@odata.etag": 
"W/\"CQAAABYAAADHcgC8Hl9tRZ/hc1wEUs1TAAAwStQm\"", "id": "AAMkAGUAAAwTXDNAAA=", 
"subject": "Offsite logistic [...]
+{"@odata.context": 
"https://graph.microsoft.com/v1.0/$metadata#users('48d31887-5fad-4d73-a9f5-3c356e68a038')/mailFolders('inbox')/messages",
 "@odata.count": 30, "value": [{"@odata.etag": 
"W/\"CQAAABYAAADHcgC8Hl9tRZ/hc1wEUs1TAAAwSp4B\"", "id": "AAMkAGUAAAwTXCMAAA=", 
"subject": "Re: Deployment window moved", "receivedDateTime": 
"2026-08-17T09:18:47Z", "isRead": true}, {"@odata.etag": 
"W/\"CQAAABYAAADHcgC8Hl9tRZ/hc1wEUs1TAAAwStQm\"", "id": "AAMkAGUAAAwTXDNAAA=", 
"subject": "Offsite logistic [...]

Reply via email to