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

hussein-awala 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 1040a319e0e Fix the durable MSGraph and PowerBI operators from 
post-merge review (#75272)
1040a319e0e is described below

commit 1040a319e0eb6a87a9ce1d1133c13d09e1dca8cc
Author: David Blain <[email protected]>
AuthorDate: Thu Oct 29 11:46:10 2026 +0100

    Fix the durable MSGraph and PowerBI operators from post-merge review 
(#75272)
    
    * Fix the durable MSGraph and PowerBI operators from post-merge review
    
    Follow-up to #74675, addressing the post-merge review
    (https://github.com/apache/airflow/pull/74675#pullrequestreview-5596928776).
    
    MSGraphAsyncOperator (durable):
    - A page is stored as {"result": ...}, so a page whose result_processor 
returned None no longer fails
      the task, and a page gone from the store (expired or dropped) fails the 
try with
      MSGraphPageNotInTaskStateStoreError and clears the store, so the next try 
starts from the first page.
    - The pages and the cursor are removed once the result is returned. A 
request that is not a GET keeps
      only its result, written before the pages are removed.
    - durable=True below Airflow 3.3 warns at parse time and is turned off, 
like ResumableJobMixin.
    - The store keys and the cursor type are private, and a dead assignment is 
gone.
    
    PowerBIDatasetRefreshOperator:
    - Fire-and-forget pushes the refresh id once. A retry that finds the 
refresh in progress counts it as
      done, since the task only had to start it.
    - The changelog and the operator docs say that the refresh now starts from 
the worker and that a
      durable refresh is not cancelled when polling fails.
    
    * Remove the MSGraph operator's own keys from the task state store instead 
of clearing it
    
    `task_state_store.clear()` deletes every key of the task instance, so a key 
a
    `result_processor` or a subclass wrote was lost when a durable GET finished 
or a
    page was missing. The pages and the cursor are now deleted by name in both
    places, and the tests keep a key of their own to show it survives. A page 
kept
    unwrapped by 15.3.0rc1 is treated like a missing page, so a retry after the
    upgrade starts again from the first page instead of failing on every try.
    
    The changelog warning is dropped: the 15.3.0 note already says it. The 
comment
    on `is_job_succeeded` says that counting an in-progress fire-and-forget 
refresh
    as done is logged and metered by the mixin as `already_succeeded`, and why.
    
    * Let the trigger start the Power BI refresh when the operator is not 
durable
    
    With `durable=False` the operator defers to `PowerBITrigger` without a 
refresh
    id, as it did before durable execution, so only the triggerer talks to 
Power BI
    and every try starts a new refresh. `durable=False` thereby restores the 
whole
    earlier behaviour, and deployments below Airflow 3.3, where `durable` is 
forced
    off, keep it without any change. The worker starts the refresh only when the
    operator is durable, which the docs and the docstrings now say.
    
    `test_execute_not_durable_starts_new_refresh_and_cancels_on_error` is 
replaced by
    a test of the deferral and a full lifecycle test of the non-durable path; 
the
    tests that start the refresh on the worker are now limited to Airflow 3.3+.
    
    * Update 
providers/microsoft/azure/src/airflow/providers/microsoft/azure/operators/powerbi.py
    
    Co-authored-by: Amogh Desai <[email protected]>
    
    * Update 
providers/microsoft/azure/src/airflow/providers/microsoft/azure/operators/powerbi.py
    
    Co-authored-by: Amogh Desai <[email protected]>
    
    * Update 
providers/microsoft/azure/src/airflow/providers/microsoft/azure/operators/msgraph.py
    
    Co-authored-by: Amogh Desai <[email protected]>
    
    * Update 
providers/microsoft/azure/src/airflow/providers/microsoft/azure/operators/msgraph.py
    
    Co-authored-by: Amogh Desai <[email protected]>
    
    * Update 
providers/microsoft/azure/src/airflow/providers/microsoft/azure/operators/msgraph.py
    
    Co-authored-by: Amogh Desai <[email protected]>
    
    * Update 
providers/microsoft/azure/src/airflow/providers/microsoft/azure/operators/msgraph.py
    
    Co-authored-by: Amogh Desai <[email protected]>
    
    * Update 
providers/microsoft/azure/src/airflow/providers/microsoft/azure/operators/msgraph.py
    
    Co-authored-by: Amogh Desai <[email protected]>
    
    * Update 
providers/microsoft/azure/src/airflow/providers/microsoft/azure/operators/msgraph.py
    
    Co-authored-by: Amogh Desai <[email protected]>
    
    * fix: Version the pages a durable MSGraphAsyncOperator keeps in the task 
state store
    
    An unwrapped page kept by 15.3.0rc1 whose result is a dict with a "result" 
key
    passed for a wrapped one and was unwrapped on resume, so an upgrade could 
drop
    the result's structure instead of starting the pagination again. The cursor 
now
    carries a version; pages under a cursor of another version are removed and 
the
    next try starts again from the first page, on both resume points
    (execute_complete through push_page or pull_pages). execute removes them and
    starts again in the same try.
    
    ---------
    
    Co-authored-by: Amogh Desai <[email protected]>
---
 .../microsoft/azure/docs/operators/msgraph.rst     |   5 +-
 .../microsoft/azure/docs/operators/powerbi.rst     |  14 +-
 .../providers/microsoft/azure/exceptions.py        |   9 ++
 .../providers/microsoft/azure/operators/msgraph.py | 124 ++++++++++++++----
 .../providers/microsoft/azure/operators/powerbi.py |  53 ++++++--
 .../providers/microsoft/azure/version_compat.py    |   2 +
 .../unit/microsoft/azure/operators/test_msgraph.py | 145 ++++++++++++++++++---
 .../unit/microsoft/azure/operators/test_powerbi.py |  87 ++++++++++++-
 8 files changed, 376 insertions(+), 63 deletions(-)

diff --git a/providers/microsoft/azure/docs/operators/msgraph.rst 
b/providers/microsoft/azure/docs/operators/msgraph.rst
index 25e2db76297..875d3df4772 100644
--- a/providers/microsoft/azure/docs/operators/msgraph.rst
+++ b/providers/microsoft/azure/docs/operators/msgraph.rst
@@ -87,7 +87,10 @@ Set ``durable=True`` to make the operator :doc:`durable 
<apache-airflow:core-con
 It keeps every processed page and the link to the next one in the
 :doc:`task state store <apache-airflow:core-concepts/task-state-store>`, so a 
retry continues at the page
 it stopped at. For a request that is not a ``GET``, it also keeps the result, 
so a retry returns it instead
-of sending the request again.
+of sending the request again. The pages and the link are removed from the 
store once the result is
+returned. A page that is no longer in the store, for instance because it 
expired under
+``[state_store] default_retention_days``, fails the try and removes the pages 
and the link, so the next try starts again
+from the first page.
 
 Pages are stored as they are, so pages larger than ``[state_store] 
max_value_storage_bytes`` need a state
 store backend that offloads large values. Durable execution requires Airflow 
3.3 or newer.
diff --git a/providers/microsoft/azure/docs/operators/powerbi.rst 
b/providers/microsoft/azure/docs/operators/powerbi.rst
index eb03eff0c50..453a6e94191 100644
--- a/providers/microsoft/azure/docs/operators/powerbi.rst
+++ b/providers/microsoft/azure/docs/operators/powerbi.rst
@@ -47,8 +47,18 @@ The operator is :doc:`durable 
<apache-airflow:core-concepts/resumable-tasks>` by
 refresh from the worker and keeps its id in the
 :doc:`task state store <apache-airflow:core-concepts/task-state-store>` before 
it defers. A retry reconnects
 to a refresh that is still in progress and does not start a new one once it 
has completed. A refresh that
-failed, or that is no longer in the dataset's refresh history, is started 
again. Set ``durable=False`` to
-start a new refresh on every try. Durable execution requires Airflow 3.3 or 
newer.
+failed, or that is no longer in the dataset's refresh history, is started 
again. Durable execution
+requires Airflow 3.3 or newer.
+
+Two things differ from earlier versions of the provider when the operator is 
durable:
+
+* The refresh is started by the worker, so the workers need network access to 
``api.powerbi.com`` too.
+  The triggerer still polls the refresh.
+* The refresh is not cancelled when polling it fails, so the retry can 
reconnect to it.
+
+Set ``durable=False`` to keep the earlier behaviour: the trigger starts the 
refresh, so only the triggerer
+talks to Power BI, every try starts a new refresh, and it is cancelled when 
polling fails. Below Airflow
+3.3 the operator behaves as with ``durable=False``.
 
 .. _howto/operator:PowerBIDatasetListOperator:
 
diff --git 
a/providers/microsoft/azure/src/airflow/providers/microsoft/azure/exceptions.py 
b/providers/microsoft/azure/src/airflow/providers/microsoft/azure/exceptions.py
index 461463e100c..a6d46897ba5 100644
--- 
a/providers/microsoft/azure/src/airflow/providers/microsoft/azure/exceptions.py
+++ 
b/providers/microsoft/azure/src/airflow/providers/microsoft/azure/exceptions.py
@@ -62,3 +62,12 @@ class 
AzureBatchRequiredParametersMissingError(AirflowException):
 
 class AzureAISearchIndexingError(AirflowException):
     """Raised when Azure AI Search rejects some documents of a write."""
+
+
+class MSGraphPageNotInTaskStateStoreError(AirflowException):
+    """
+    Raised when the pages kept by a durable MSGraphAsyncOperator cannot be 
read back from the task state store.
+
+    A page is gone, or an earlier version of the provider kept the pages; the 
next try starts again from the
+    first page.
+    """
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 6b6d383e567..59b6da91dab 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
@@ -29,11 +29,13 @@ from typing import (
 
 from airflow.exceptions import AirflowProviderDeprecationWarning
 from airflow.providers.common.compat.sdk import XCOM_RETURN_KEY, 
AirflowException, BaseOperator, TaskDeferred
+from airflow.providers.microsoft.azure.exceptions import 
MSGraphPageNotInTaskStateStoreError
 from airflow.providers.microsoft.azure.hooks.msgraph import 
KiotaRequestAdapterHook
 from airflow.providers.microsoft.azure.triggers.msgraph import (
     MSGraphTrigger,
     ResponseSerializer,
 )
+from airflow.providers.microsoft.azure.version_compat import AIRFLOW_V_3_3_PLUS
 
 if TYPE_CHECKING:
     from io import BytesIO
@@ -44,14 +46,18 @@ if TYPE_CHECKING:
     from airflow.sdk.execution_time.context import TaskStateStoreAccessor
 
 # Keys in the task state store of a durable MSGraphAsyncOperator.
-CURSOR_KEY = "msgraph_cursor"
-PAGE_KEY_PREFIX = "msgraph_page_"
-COMPLETED_KEY = "msgraph_completed"
+_CURSOR_KEY = "msgraph_cursor"
+_PAGE_KEY_PREFIX = "msgraph_page_"
+_COMPLETED_KEY = "msgraph_completed"
+# The version of the kept pages and their cursor. 15.3.0rc1 kept the pages 
unwrapped, under a cursor without
+# a version; a cursor of another version is not read back, its pages go and 
the pagination starts again.
+_CURSOR_VERSION = 1
 
 
-class PaginationCursor(TypedDict):
+class _PaginationCursor(TypedDict):
     """Where a durable MSGraphAsyncOperator continues: the next link and the 
number of pages kept."""
 
+    version: int
     url: str
     query_parameters: dict[str, Any] | None
     pages: int
@@ -126,9 +132,10 @@ class MSGraphAsyncOperator(BaseOperator):
         crash, resumes instead of starting over (default is False). Every 
processed page is kept with the
         link to the next one, so the retry continues at the page it stopped 
at. The result of a request
         that is not a GET is kept too, so the retry returns it instead of 
sending the request again. A
-        crash between a response and its write to the store still repeats that 
one request. Pages are
-        stored as they are, so pages larger than the store's 
``max_value_storage_bytes`` need a state
-        store backend that offloads large values. Needs Airflow 3.3 or later.
+        crash between a response and its write to the store still repeats that 
one request. The pages
+        are removed from the store once the result is returned. Pages are 
stored as they are, so pages
+        larger than the store's ``max_value_storage_bytes`` need a state store 
backend that offloads
+        large values. Needs Airflow 3.3 or later.
     """
 
     template_fields: Sequence[str] = (
@@ -167,6 +174,9 @@ class MSGraphAsyncOperator(BaseOperator):
         **kwargs: Any,
     ):
         super().__init__(**kwargs)
+        if durable and not AIRFLOW_V_3_3_PLUS:
+            warnings.warn("`durable` has no effect on Airflow versions below 
3.3.", UserWarning, stacklevel=2)
+            durable = False
         self.url = url
         self.response_type = response_type
         self.path_parameters = path_parameters
@@ -189,17 +199,23 @@ class MSGraphAsyncOperator(BaseOperator):
 
     def execute(self, context: Context) -> Any:
         if task_state_store := self.get_task_state_store(context):
-            if completed := task_state_store.get(COMPLETED_KEY):
+            if completed := task_state_store.get(_COMPLETED_KEY):
                 self.log.info("Returning the result of the request completed 
by an earlier try.")
                 return cast("dict[str, Any]", completed)["result"]
             if cursor := self.get_cursor(task_state_store):
-                self.log.info("Resuming at page %s: %s", cursor["pages"] + 1, 
cursor["url"])
-                self.query_parameters = cursor["query_parameters"]
-                self.defer_to_next_link(
-                    url=cursor["url"],
-                    query_parameters=cursor["query_parameters"],
-                    method_name=self.execute_complete.__name__,
-                )
+                if cursor.get("version") != _CURSOR_VERSION:
+                    self.log.info(
+                        "Removing the pages kept by an earlier version of the 
provider, "
+                        "starting again from the first page."
+                    )
+                    self._remove_pages(task_state_store, cursor)
+                else:
+                    self.log.info("Resuming at page %s: %s", cursor["pages"] + 
1, cursor["url"])
+                    self.defer_to_next_link(
+                        url=cursor["url"],
+                        query_parameters=cursor["query_parameters"],
+                        method_name=self.execute_complete.__name__,
+                    )
         elif self.durable:
             self.log.warning(
                 "task_state_store not available in context, crash recovery is 
disabled for this run"
@@ -300,8 +316,8 @@ class MSGraphAsyncOperator(BaseOperator):
                     results = result
             elif task_state_store:
                 results = self.pull_pages(task_state_store)
-            if task_state_store and self.method.upper() != "GET":
-                task_state_store.set(COMPLETED_KEY, {"result": results})
+            if task_state_store:
+                self._clean_up(task_state_store, results=results)
             return results
         return None
 
@@ -310,15 +326,48 @@ class MSGraphAsyncOperator(BaseOperator):
         return context.get("task_state_store") if self.durable else None
 
     @staticmethod
-    def get_cursor(task_state_store: TaskStateStoreAccessor) -> 
PaginationCursor | None:
-        return cast("PaginationCursor | None", 
task_state_store.get(CURSOR_KEY))
+    def get_cursor(task_state_store: TaskStateStoreAccessor) -> 
_PaginationCursor | None:
+        return cast("_PaginationCursor | None", 
task_state_store.get(_CURSOR_KEY))
+
+    def _get_cursor_of_this_version(
+        self, task_state_store: TaskStateStoreAccessor
+    ) -> _PaginationCursor | None:
+        """
+        Return the cursor the kept pages can be read back with, or none when 
no page is kept.
+
+        The pages kept by an earlier version of the provider are not read 
back: an unwrapped page of
+        15.3.0rc1 that happens to be a dict with a ``result`` key would pass 
for a wrapped one. They go,
+        with their cursor, so the next try starts again from the first page.
+        """
+        cursor = self.get_cursor(task_state_store)
+        if cursor is None or cursor.get("version") == _CURSOR_VERSION:
+            return cursor
+        self._remove_pages(task_state_store, cursor)
+        raise MSGraphPageNotInTaskStateStoreError(
+            "The pages were kept by an earlier version of the provider, so the 
next try starts again from "
+            "the first page."
+        )
 
     def pull_pages(self, task_state_store: TaskStateStoreAccessor) -> 
list[Any]:
-        """Return the results of the pages processed so far, kept in the task 
state store."""
+        """
+        Return the results of the pages processed so far, kept in the task 
state store.
+
+        A page is kept wrapped in a dict, so a page whose result is ``None`` 
is told apart from a page
+        that is gone, for instance because it expired under ``[state_store] 
default_retention_days``.
+        A missing page removes the pages and the cursor, so the next try 
starts again from the first page
+        instead of returning results with a hole in them.
+        """
         results: list[Any] = []
-        cursor = self.get_cursor(task_state_store)
+        cursor = self._get_cursor_of_this_version(task_state_store)
         for page in range(cursor["pages"] if cursor else 0):
-            self.append_result(results=results, 
result=task_state_store.get(f"{PAGE_KEY_PREFIX}{page}"))
+            kept = task_state_store.get(f"{_PAGE_KEY_PREFIX}{page}")
+            if not isinstance(kept, dict) or "result" not in kept:
+                self._remove_pages(task_state_store, cursor)
+                raise MSGraphPageNotInTaskStateStoreError(
+                    f"Page {page + 1} is no longer in the task state store, so 
the next try starts again from "
+                    "the first page."
+                )
+            self.append_result(results=results, result=cast("dict[str, Any]", 
kept)["result"])
         return results
 
     def push_page(
@@ -330,18 +379,43 @@ class MSGraphAsyncOperator(BaseOperator):
         The page goes in before the cursor that counts it, so a crash in 
between only fetches that page
         again.
         """
-        cursor = self.get_cursor(task_state_store)
+        cursor = self._get_cursor_of_this_version(task_state_store)
         page = cursor["pages"] if cursor else 0
-        task_state_store.set(f"{PAGE_KEY_PREFIX}{page}", result)
+        task_state_store.set(f"{_PAGE_KEY_PREFIX}{page}", {"result": result})
         task_state_store.set(
-            CURSOR_KEY,
+            _CURSOR_KEY,
             {
+                "version": _CURSOR_VERSION,
                 "url": deferred.trigger.url,
                 "query_parameters": (deferred.kwargs or 
{}).get("query_parameters"),
                 "pages": page + 1,
             },
         )
 
+    def _clean_up(self, task_state_store: TaskStateStoreAccessor, results: 
Any) -> None:
+        """
+        Remove the pages and the cursor from the task state store once the 
result is returned.
+
+        A non GET result is written before the pages go, so a crash in between 
does not resend the request.
+        """
+        if self.method.upper() != "GET":
+            task_state_store.set(_COMPLETED_KEY, {"result": results})
+        self._remove_pages(task_state_store, self.get_cursor(task_state_store))
+
+    @staticmethod
+    def _remove_pages(task_state_store: TaskStateStoreAccessor, cursor: 
_PaginationCursor | None) -> None:
+        """
+        Delete the pages and the cursor by their keys.
+
+        ``task_state_store.clear()`` would delete every key of the task 
instance, including keys a
+        ``result_processor`` or a subclass wrote.
+        """
+        if cursor is None:
+            return
+        for page in range(cursor["pages"]):
+            task_state_store.delete(f"{_PAGE_KEY_PREFIX}{page}")
+        task_state_store.delete(_CURSOR_KEY)
+
     @classmethod
     def append_result(
         cls,
diff --git 
a/providers/microsoft/azure/src/airflow/providers/microsoft/azure/operators/powerbi.py
 
b/providers/microsoft/azure/src/airflow/providers/microsoft/azure/operators/powerbi.py
index 8ae2b979389..aa45d84e653 100644
--- 
a/providers/microsoft/azure/src/airflow/providers/microsoft/azure/operators/powerbi.py
+++ 
b/providers/microsoft/azure/src/airflow/providers/microsoft/azure/operators/powerbi.py
@@ -69,10 +69,11 @@ class PowerBIDatasetRefreshOperator(ResumableJobMixin, 
BaseOperator):
     """
     Refreshes a Power BI dataset.
 
-    The refresh is started from the worker, and its id is kept in the task 
state store before the task
-    defers to wait for it. A retry, for instance after a worker crash, 
reconnects to that refresh while it
-    is still in progress, and does not start a new one once it has completed. 
This needs Airflow 3.3 or
-    later; on older versions every try starts a new refresh.
+    When durable, the refresh is started from the worker, and its id is kept 
in the task state store
+    before the task defers to wait for it. A retry, for instance after a 
worker crash, reconnects to that
+    refresh while it is still in progress, and does not start a new one once 
it has completed. This needs
+    Airflow 3.3 or later. Otherwise the trigger starts the refresh, as in 
earlier versions of this
+    provider: only the triggerer talks to Power BI, and every try starts a new 
refresh.
 
     .. seealso::
         For more information on how to use this operator, take a look at the 
guide:
@@ -88,8 +89,11 @@ class PowerBIDatasetRefreshOperator(ResumableJobMixin, 
BaseOperator):
     :param wait_for_completion: If True, wait for the dataset refresh to 
complete. If False, trigger the refresh and return immediately without waiting.
     :param deferrable: This parameter is deprecated and no longer has any 
effect. The operator now always
         uses deferrable execution.
-    :param durable: Reconnect to the refresh started by an earlier try instead 
of starting a new one
-        (default is True). Needs Airflow 3.3 or later.
+    :param durable: Start the refresh from the worker and reconnect to it on a 
retry instead of starting
+        a new one (default is True). A durable refresh is not cancelled when 
polling it fails, so that
+        the retry can reconnect to it. Set it to False to keep the behaviour 
of earlier versions: the
+        trigger starts the refresh, every try starts a new one, and it is 
cancelled when polling fails.
+        Needs Airflow 3.3 or later; below it the operator behaves as with 
``durable=False``.
     """
 
     external_id_key = "powerbi_dataset_refresh_id"
@@ -139,6 +143,23 @@ class PowerBIDatasetRefreshOperator(ResumableJobMixin, 
BaseOperator):
 
     def execute(self, context: Context):
         """Refresh the Power BI Dataset."""
+        if not self.durable:
+            # As in earlier versions of this provider: the trigger starts the 
refresh, so only the
+            # triggerer talks to Power BI, and every try starts a new refresh.
+            self.defer(
+                trigger=PowerBITrigger(
+                    conn_id=self.conn_id,
+                    group_id=self.group_id,
+                    dataset_id=self.dataset_id,
+                    timeout=self.timeout,
+                    proxies=self.proxies,
+                    api_version=self.api_version,
+                    check_interval=self.check_interval,
+                    wait_for_termination=False,
+                    request_body=self.request_body,
+                ),
+                method_name=self.handle_refresh.__name__,
+            )
         return self.execute_resumable(context)
 
     def submit_job(self, context: Context) -> str:
@@ -172,19 +193,24 @@ class PowerBIDatasetRefreshOperator(ResumableJobMixin, 
BaseOperator):
         return refresh_details[PowerBIDatasetRefreshFields.STATUS.value]
 
     def is_job_active(self, status: str) -> bool:
-        return status == PowerBIDatasetRefreshStatus.IN_PROGRESS
+        # Without wait_for_completion a retry does not wait for a refresh in 
progress either:
+        # is_job_succeeded counts it as done.
+        return self.wait_for_completion and status == 
PowerBIDatasetRefreshStatus.IN_PROGRESS
 
     def is_job_succeeded(self, status: str) -> bool:
-        return status == PowerBIDatasetRefreshStatus.COMPLETED
+        if status == PowerBIDatasetRefreshStatus.COMPLETED:
+            return True
+        # Without wait_for_completion, is_job_succeeded counts an in-progress 
refresh as done.
+        return not self.wait_for_completion and status == 
PowerBIDatasetRefreshStatus.IN_PROGRESS
 
     def poll_until_complete(self, external_id: JsonValue, context: Context) -> 
None:
         dataset_refresh_id = str(external_id)
-        self._push_refresh_xcom(context, dataset_refresh_id)
-
         if not self.wait_for_completion:
+            # get_job_result pushes the refresh id.
             self.log.info("Triggered dataset refresh %s (fire-and-forget)", 
dataset_refresh_id)
             return
 
+        self._push_refresh_xcom(context, dataset_refresh_id)
         self.defer(
             trigger=PowerBITrigger(
                 conn_id=self.conn_id,
@@ -202,7 +228,8 @@ class PowerBIDatasetRefreshOperator(ResumableJobMixin, 
BaseOperator):
         )
 
     def get_job_result(self, external_id: JsonValue, context: Context) -> None:
-        # With wait_for_completion the mixin only gets here for a refresh an 
earlier try saw complete.
+        # With wait_for_completion the mixin only gets here for a refresh an 
earlier try saw complete,
+        # otherwise poll_until_complete defers and execute_complete pushes the 
id and the status.
         self._push_refresh_xcom(
             context,
             str(external_id),
@@ -222,8 +249,8 @@ class PowerBIDatasetRefreshOperator(ResumableJobMixin, 
BaseOperator):
         """
         Handle refresh-trigger event and optionally defer again to wait for 
refresh completion.
 
-        Only tasks deferred by an earlier version of this provider, whose 
trigger started the refresh,
-        still resume here.
+        The trigger started the refresh: the task is not durable, or was 
deferred by an earlier version
+        of this provider.
 
         :param context: Airflow context dictionary
         :param event: Event dict from trigger with status and 
dataset_refresh_id
diff --git 
a/providers/microsoft/azure/src/airflow/providers/microsoft/azure/version_compat.py
 
b/providers/microsoft/azure/src/airflow/providers/microsoft/azure/version_compat.py
index 209e8b63f35..8b5cdcf5cc4 100644
--- 
a/providers/microsoft/azure/src/airflow/providers/microsoft/azure/version_compat.py
+++ 
b/providers/microsoft/azure/src/airflow/providers/microsoft/azure/version_compat.py
@@ -34,8 +34,10 @@ def get_base_airflow_version_tuple() -> tuple[int, int, int]:
 
 AIRFLOW_V_3_0_PLUS = get_base_airflow_version_tuple() >= (3, 0, 0)
 AIRFLOW_V_3_1_PLUS = get_base_airflow_version_tuple() >= (3, 1, 0)
+AIRFLOW_V_3_3_PLUS = get_base_airflow_version_tuple() >= (3, 3, 0)
 
 __all__ = [
     "AIRFLOW_V_3_0_PLUS",
     "AIRFLOW_V_3_1_PLUS",
+    "AIRFLOW_V_3_3_PLUS",
 ]
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 772be3739df..3d6ace80136 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
@@ -28,10 +28,11 @@ import pytest
 
 from airflow.exceptions import AirflowProviderDeprecationWarning
 from airflow.providers.common.compat.sdk import AirflowException, Context, 
TaskDeferred, timezone
+from airflow.providers.microsoft.azure.exceptions import 
MSGraphPageNotInTaskStateStoreError
 from airflow.providers.microsoft.azure.operators.msgraph import (
-    COMPLETED_KEY,
-    CURSOR_KEY,
-    PAGE_KEY_PREFIX,
+    _COMPLETED_KEY,
+    _CURSOR_KEY,
+    _PAGE_KEY_PREFIX,
     MSGraphAsyncOperator,
     execute_callable,
 )
@@ -40,6 +41,7 @@ from airflow.triggers.base import TriggerEvent
 from tests_common.test_utils.file_loading import load_file_from_resources, 
load_json_from_resources
 from tests_common.test_utils.mock_context import mock_context
 from tests_common.test_utils.operators.run_deferrable import execute_operator
+from tests_common.test_utils.version_compat import AIRFLOW_V_3_3_PLUS
 from unit.microsoft.azure.test_utils import (
     mock_json_response,
     mock_response,
@@ -59,8 +61,13 @@ class TaskStateStore:
         return self.values.get(key, default)
 
     def set(self, key, value, *, retention=None):
+        if value is None:
+            raise ValueError("Cannot set value as None")
         self.values[key] = value
 
+    def delete(self, key):
+        self.values.pop(key, None)
+
 
 def users_event(name: str) -> dict[str, Any]:
     users = load_json_from_resources(dirname(__file__), "..", "resources", 
name)
@@ -77,6 +84,11 @@ def users_operator(**kwargs) -> MSGraphAsyncOperator:
     )
 
 
+def kept_cursor(pages: int = 1) -> dict[str, Any]:
+    """A cursor as this version of the provider keeps it, pointing at the next 
page."""
+    return {"version": 1, "url": "users/delta()?$skiptoken=abc", 
"query_parameters": None, "pages": pages}
+
+
 class TestMSGraphAsyncOperator:
     def test_execute_with_old_result_processor_signature(self):
         users = load_json_from_resources(dirname(__file__), "..", "resources", 
"users.json")
@@ -548,6 +560,15 @@ class TestMSGraphAsyncOperator:
             assert len(recorded_warnings) == 0
 
 
[email protected]("airflow.providers.microsoft.azure.operators.msgraph.AIRFLOW_V_3_3_PLUS",
 False)
+def test_durable_below_airflow_3_3_warns_and_is_disabled():
+    with pytest.warns(UserWarning, match="`durable` has no effect on Airflow 
versions below 3.3"):
+        operator = users_operator(durable=True)
+
+    assert operator.durable is False
+
+
[email protected](not AIRFLOW_V_3_3_PLUS, reason="The task state store 
arrived in Airflow 3.3")
 class TestDurableMSGraphAsyncOperator:
     def test_keeps_the_page_and_the_link_to_the_next_one(self):
         operator = users_operator(durable=True)
@@ -559,34 +580,54 @@ class TestDurableMSGraphAsyncOperator:
 
         users = json.loads(users_event("users.json")["response"])
         assert task_state_store.values == {
-            f"{PAGE_KEY_PREFIX}0": users["value"],
-            CURSOR_KEY: {"url": users["@odata.nextLink"], "query_parameters": 
None, "pages": 1},
+            f"{_PAGE_KEY_PREFIX}0": {"result": users["value"]},
+            _CURSOR_KEY: {
+                "version": 1,
+                "url": users["@odata.nextLink"],
+                "query_parameters": None,
+                "pages": 1,
+            },
         }
         assert exc.value.trigger.url == users["@odata.nextLink"]
         context["ti"].xcom_push.assert_not_called()
 
+    def test_keeps_a_page_whose_result_is_none(self):
+        operator = users_operator(durable=True)
+        task_state_store = TaskStateStore()
+        context = {"ti": mock.MagicMock(), "task_state_store": 
task_state_store}
+        page_without_value = {
+            "@odata.nextLink": 
"https://graph.microsoft.com/v1.0/users/delta()?$skiptoken=abc"
+        }
+        event = {"status": "success", "type": "builtins.dict", "response": 
json.dumps(page_without_value)}
+
+        with pytest.raises(TaskDeferred):
+            operator.execute_complete(context=context, event=event)
+
+        assert task_state_store.values[f"{_PAGE_KEY_PREFIX}0"] == {"result": 
None}
+
     def test_intermediate_page_reads_only_the_cursor(self):
         operator = users_operator(durable=True)
         task_state_store = TaskStateStore(
-            **{f"{PAGE_KEY_PREFIX}{page}": [{"id": str(page)}] for page in 
range(3)},
-            **{CURSOR_KEY: {"url": "users/delta()?$skiptoken=abc", 
"query_parameters": None, "pages": 3}},
+            **{f"{_PAGE_KEY_PREFIX}{page}": {"result": [{"id": str(page)}]} 
for page in range(3)},
+            **{_CURSOR_KEY: kept_cursor(pages=3)},
         )
         context = {"ti": mock.MagicMock(), "task_state_store": 
task_state_store}
 
         with pytest.raises(TaskDeferred):
             operator.execute_complete(context=context, 
event=users_event("users.json"))
 
-        assert task_state_store.reads == [CURSOR_KEY]
-        assert task_state_store.values[CURSOR_KEY]["pages"] == 4
+        assert task_state_store.reads == [_CURSOR_KEY]
+        assert task_state_store.values[_CURSOR_KEY]["pages"] == 4
 
     def test_execute_resumes_at_the_kept_link(self):
         operator = users_operator(durable=True, query_parameters={"$top": 12})
         cursor = {
+            "version": 1,
             "url": "users/delta()?$skiptoken=abc",
             "query_parameters": {"$top": 12, "$skip": 12},
             "pages": 1,
         }
-        context = {"ti": mock.MagicMock(), "task_state_store": 
TaskStateStore(**{CURSOR_KEY: cursor})}
+        context = {"ti": mock.MagicMock(), "task_state_store": 
TaskStateStore(**{_CURSOR_KEY: cursor})}
 
         with pytest.raises(TaskDeferred) as exc:
             operator.execute(context)
@@ -603,8 +644,9 @@ class TestDurableMSGraphAsyncOperator:
         last_page = 
json.loads(users_event("next_users.json")["response"])["value"]
         task_state_store = TaskStateStore(
             **{
-                f"{PAGE_KEY_PREFIX}0": first_page,
-                CURSOR_KEY: {"url": "users/delta()?$skiptoken=abc", 
"query_parameters": None, "pages": 1},
+                f"{_PAGE_KEY_PREFIX}0": {"result": first_page},
+                _CURSOR_KEY: kept_cursor(),
+                "written_by_the_dag": "kept",
             }
         )
         context = {"ti": mock.MagicMock(), "task_state_store": 
task_state_store}
@@ -612,7 +654,65 @@ class TestDurableMSGraphAsyncOperator:
         results = operator.execute_complete(context=context, 
event=users_event("next_users.json"))
 
         assert results == first_page + last_page
-        assert COMPLETED_KEY not in task_state_store.values
+        assert task_state_store.values == {"written_by_the_dag": "kept"}
+
+    def test_page_no_longer_in_the_store_fails_and_removes_the_pages(self):
+        operator = users_operator(durable=True)
+        task_state_store = TaskStateStore(
+            **{
+                f"{_PAGE_KEY_PREFIX}0": {"result": [{"id": "0"}]},
+                _CURSOR_KEY: kept_cursor(pages=2),
+                "written_by_the_dag": "kept",
+            }
+        )
+        context = {"ti": mock.MagicMock(), "task_state_store": 
task_state_store}
+
+        with pytest.raises(MSGraphPageNotInTaskStateStoreError, match="Page 2 
is no longer"):
+            operator.execute_complete(context=context, 
event=users_event("next_users.json"))
+
+        assert task_state_store.values == {"written_by_the_dag": "kept"}
+
+    @pytest.mark.parametrize(
+        "page",
+        [[{"id": "0"}], {"result": "value", "metadata": "keep"}],
+        ids=["list", "dict_with_a_result_key"],
+    )
+    @pytest.mark.parametrize("event", ["users.json", "next_users.json"], 
ids=["next_page", "last_page"])
+    def test_pages_kept_by_an_earlier_version_fail_and_are_removed(self, page, 
event):
+        """15.3.0rc1 kept the pages unwrapped, under a cursor without a 
version."""
+        operator = users_operator(durable=True)
+        task_state_store = TaskStateStore(
+            **{
+                f"{_PAGE_KEY_PREFIX}0": page,
+                _CURSOR_KEY: {"url": "users/delta()?$skiptoken=abc", 
"query_parameters": None, "pages": 1},
+                "written_by_the_dag": "kept",
+            }
+        )
+        context = {"ti": mock.MagicMock(), "task_state_store": 
task_state_store}
+
+        with pytest.raises(MSGraphPageNotInTaskStateStoreError, match="earlier 
version of the provider"):
+            operator.execute_complete(context=context, 
event=users_event(event))
+
+        assert task_state_store.values == {"written_by_the_dag": "kept"}
+        context["ti"].xcom_push.assert_not_called()
+
+    def 
test_execute_removes_the_pages_kept_by_an_earlier_version_and_starts_again(self):
+        operator = users_operator(durable=True)
+        task_state_store = TaskStateStore(
+            **{
+                f"{_PAGE_KEY_PREFIX}0": {"result": "value", "metadata": 
"keep"},
+                _CURSOR_KEY: {"url": "users/delta()?$skiptoken=abc", 
"query_parameters": None, "pages": 1},
+                "written_by_the_dag": "kept",
+            }
+        )
+        context = {"ti": mock.MagicMock(), "task_state_store": 
task_state_store}
+
+        with pytest.raises(TaskDeferred) as exc:
+            operator.execute(context)
+
+        assert exc.value.trigger.url == operator.url
+        assert exc.value.trigger.pagination_link is False
+        assert task_state_store.values == {"written_by_the_dag": "kept"}
 
     def test_keeps_the_result_of_a_request_that_is_not_a_get(self):
         operator = users_operator(durable=True, method="POST", 
data={"requestBody": "value"})
@@ -621,13 +721,28 @@ class TestDurableMSGraphAsyncOperator:
 
         results = operator.execute_complete(context=context, 
event=users_event("next_users.json"))
 
-        assert task_state_store.values == {COMPLETED_KEY: {"result": results}}
+        assert task_state_store.values == {_COMPLETED_KEY: {"result": results}}
+
+    def 
test_keeps_only_the_result_of_a_paginated_request_that_is_not_a_get(self):
+        operator = users_operator(durable=True, method="POST", 
data={"requestBody": "value"})
+        task_state_store = TaskStateStore(
+            **{
+                f"{_PAGE_KEY_PREFIX}0": {"result": [{"id": "0"}]},
+                _CURSOR_KEY: kept_cursor(),
+            }
+        )
+        context = {"ti": mock.MagicMock(), "task_state_store": 
task_state_store}
+
+        results = operator.execute_complete(context=context, 
event=users_event("next_users.json"))
+
+        assert results[0] == {"id": "0"}
+        assert task_state_store.values == {_COMPLETED_KEY: {"result": results}}
 
     def 
test_execute_returns_the_kept_result_without_sending_the_request_again(self):
         operator = users_operator(durable=True, method="POST", 
data={"requestBody": "value"})
         context = {
             "ti": mock.MagicMock(),
-            "task_state_store": TaskStateStore(**{COMPLETED_KEY: {"result": 
[{"id": "1"}]}}),
+            "task_state_store": TaskStateStore(**{_COMPLETED_KEY: {"result": 
[{"id": "1"}]}}),
         }
 
         with mock.patch.object(operator, "defer") as mock_defer:
diff --git 
a/providers/microsoft/azure/tests/unit/microsoft/azure/operators/test_powerbi.py
 
b/providers/microsoft/azure/tests/unit/microsoft/azure/operators/test_powerbi.py
index 5a0dcb781e0..4a9ae25cee3 100644
--- 
a/providers/microsoft/azure/tests/unit/microsoft/azure/operators/test_powerbi.py
+++ 
b/providers/microsoft/azure/tests/unit/microsoft/azure/operators/test_powerbi.py
@@ -273,6 +273,7 @@ class TestPowerBIDatasetRefreshOperator:
 
         assert url == EXPECTED_ITEM_RUN_OP_EXTRA_LINK
 
+    @pytest.mark.skipif(not AIRFLOW_V_3_3_PLUS, reason="The worker starts the 
refresh only from Airflow 3.3")
     @mock.patch.object(PowerBIHook, "trigger_dataset_refresh", 
return_value=NEW_REFRESH_REQUEST_ID)
     def test_execute_starts_refresh_on_worker_and_defers_to_poll(self, 
mock_trigger_dataset_refresh):
         operator = PowerBIDatasetRefreshOperator(**CONFIG)
@@ -292,10 +293,10 @@ class TestPowerBIDatasetRefreshOperator:
         context["ti"].xcom_push.assert_called_once_with(
             key=f"{TASK_ID}.powerbi_dataset_refresh_id", 
value=NEW_REFRESH_REQUEST_ID
         )
-        if AIRFLOW_V_3_3_PLUS:
-            assert task_state_store.values == {REFRESH_ID_KEY: 
NEW_REFRESH_REQUEST_ID}
-            assert exc.value.trigger.cancel_on_error is False
+        assert task_state_store.values == {REFRESH_ID_KEY: 
NEW_REFRESH_REQUEST_ID}
+        assert exc.value.trigger.cancel_on_error is False
 
+    @pytest.mark.skipif(not AIRFLOW_V_3_3_PLUS, reason="The worker starts the 
refresh only from Airflow 3.3")
     @mock.patch.object(PowerBIHook, "trigger_dataset_refresh", 
return_value=NEW_REFRESH_REQUEST_ID)
     def test_execute_fire_and_forget_mode(self, mock_trigger_dataset_refresh):
         """Fire-and-forget starts the refresh and returns without deferring."""
@@ -305,10 +306,34 @@ class TestPowerBIDatasetRefreshOperator:
         assert operator.execute(context) is None
 
         mock_trigger_dataset_refresh.assert_called_once()
-        context["ti"].xcom_push.assert_called_with(
+        context["ti"].xcom_push.assert_called_once_with(
             key=f"{TASK_ID}.powerbi_dataset_refresh_id", 
value=NEW_REFRESH_REQUEST_ID
         )
 
+    @pytest.mark.skipif(not AIRFLOW_V_3_3_PLUS, reason="The task state store 
arrived in Airflow 3.3")
+    @pytest.mark.parametrize(
+        "status", [PowerBIDatasetRefreshStatus.IN_PROGRESS, 
PowerBIDatasetRefreshStatus.COMPLETED]
+    )
+    @mock.patch.object(PowerBIHook, "get_refresh_history")
+    @mock.patch.object(PowerBIHook, "trigger_dataset_refresh")
+    def test_execute_fire_and_forget_does_not_refresh_again_once_started(
+        self, mock_trigger_dataset_refresh, mock_get_refresh_history, status
+    ):
+        mock_get_refresh_history.return_value = [previous_refresh(status)]
+        operator = PowerBIDatasetRefreshOperator(**CONFIG, 
wait_for_completion=False)
+        context = {
+            "ti": MagicMock(),
+            "task_state_store": TaskStateStore(**{REFRESH_ID_KEY: 
PREVIOUS_REFRESH_REQUEST_ID}),
+        }
+
+        assert operator.execute(context) is None
+
+        mock_trigger_dataset_refresh.assert_not_called()
+        context["ti"].xcom_push.assert_called_once_with(
+            key=f"{TASK_ID}.powerbi_dataset_refresh_id", 
value=PREVIOUS_REFRESH_REQUEST_ID
+        )
+
+    @pytest.mark.skipif(not AIRFLOW_V_3_3_PLUS, reason="The worker starts the 
refresh only from Airflow 3.3")
     @mock.patch.object(PowerBIHook, "trigger_dataset_refresh", 
return_value=None)
     def test_execute_raises_when_refresh_has_no_id(self, 
mock_trigger_dataset_refresh):
         operator = PowerBIDatasetRefreshOperator(**CONFIG)
@@ -485,7 +510,10 @@ class TestPowerBIDatasetRefreshOperator:
         assert task_state_store.values == {REFRESH_ID_KEY: 
PREVIOUS_REFRESH_REQUEST_ID}
 
     @mock.patch.object(PowerBIHook, "trigger_dataset_refresh", 
return_value=NEW_REFRESH_REQUEST_ID)
-    def test_execute_not_durable_starts_new_refresh_and_cancels_on_error(self, 
mock_trigger_dataset_refresh):
+    def test_execute_not_durable_defers_to_the_trigger_that_starts_the_refresh(
+        self, mock_trigger_dataset_refresh
+    ):
+        """Without durable execution the worker never talks to Power BI, as 
before the operator was durable."""
         operator = PowerBIDatasetRefreshOperator(**CONFIG, durable=False)
         task_state_store = TaskStateStore(**{REFRESH_ID_KEY: 
PREVIOUS_REFRESH_REQUEST_ID})
         context = {"ti": MagicMock(), "task_state_store": task_state_store}
@@ -493,11 +521,56 @@ class TestPowerBIDatasetRefreshOperator:
         with pytest.raises(TaskDeferred) as exc:
             operator.execute(context)
 
-        mock_trigger_dataset_refresh.assert_called_once()
-        assert exc.value.trigger.dataset_refresh_id == NEW_REFRESH_REQUEST_ID
+        mock_trigger_dataset_refresh.assert_not_called()
+        context["ti"].xcom_push.assert_not_called()
+        assert isinstance(exc.value.trigger, PowerBITrigger)
+        assert exc.value.trigger.dataset_refresh_id is None
+        assert exc.value.trigger.request_body == REQUEST_BODY
+        assert exc.value.trigger.wait_for_termination is False
         assert exc.value.trigger.cancel_on_error is True
+        assert exc.value.method_name == "handle_refresh"
         assert task_state_store.values == {REFRESH_ID_KEY: 
PREVIOUS_REFRESH_REQUEST_ID}
 
+    @mock.patch.object(PowerBIHook, "get_refresh_details_by_refresh_id")
+    @mock.patch.object(PowerBIHook, "trigger_dataset_refresh", 
return_value=NEW_REFRESH_REQUEST_ID)
+    def test_execute_not_durable_full_lifecycle(
+        self, mock_trigger_dataset_refresh, 
mock_get_refresh_details_by_refresh_id
+    ):
+        """Assert the full lifecycle: start in the trigger, defer again to 
poll, complete on the worker."""
+        mock_get_refresh_details_by_refresh_id.return_value = {
+            PowerBIDatasetRefreshFields.STATUS.value: 
PowerBIDatasetRefreshStatus.COMPLETED,
+            PowerBIDatasetRefreshFields.ERROR.value: None,
+        }
+        operator = PowerBIDatasetRefreshOperator(**CONFIG, durable=False)
+        context = {"ti": MagicMock(), "task_state_store": TaskStateStore()}
+
+        with pytest.raises(TaskDeferred) as started:
+            operator.execute(context)
+        [started_event] = run_trigger(started.value.trigger)
+        with pytest.raises(TaskDeferred) as polled:
+            getattr(operator, started.value.method_name)(context=context, 
event=started_event.payload)
+        [completed_event] = run_trigger(polled.value.trigger)
+        assert (
+            getattr(operator, polled.value.method_name)(context=context, 
event=completed_event.payload)
+            is None
+        )
+
+        mock_trigger_dataset_refresh.assert_called_once_with(
+            dataset_id=DATASET_ID, group_id=GROUP_ID, request_body=REQUEST_BODY
+        )
+        assert started_event.payload["dataset_refresh_id"] == 
NEW_REFRESH_REQUEST_ID
+        assert polled.value.trigger.dataset_refresh_id == 
NEW_REFRESH_REQUEST_ID
+        assert completed_event.payload["dataset_refresh_status"] == 
PowerBIDatasetRefreshStatus.COMPLETED
+        assert context["ti"].xcom_push.call_args_list == [
+            mock.call(key=f"{TASK_ID}.powerbi_dataset_refresh_id", 
value=NEW_REFRESH_REQUEST_ID),
+            mock.call(key=f"{TASK_ID}.powerbi_dataset_refresh_id", 
value=NEW_REFRESH_REQUEST_ID),
+            mock.call(
+                key=f"{TASK_ID}.powerbi_dataset_refresh_status", 
value=PowerBIDatasetRefreshStatus.COMPLETED
+            ),
+        ]
+        assert context["task_state_store"].values == {}
+
+    @pytest.mark.skipif(not AIRFLOW_V_3_3_PLUS, reason="The worker starts the 
refresh only from Airflow 3.3")
     @mock.patch.object(PowerBIHook, "get_refresh_details_by_refresh_id")
     @mock.patch.object(PowerBIHook, "trigger_dataset_refresh", 
return_value=NEW_REFRESH_REQUEST_ID)
     def test_execute_operator_wait_for_completion_full_lifecycle(

Reply via email to