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 e74e2584cab Let MSGraphAsyncOperator and MSGraphSensor start from the 
triggerer (#53009)
e74e2584cab is described below

commit e74e2584cab6251741d72f962cb014d1ad316e48
Author: David Blain <[email protected]>
AuthorDate: Fri Nov 13 07:03:47 2026 +0100

    Let MSGraphAsyncOperator and MSGraphSensor start from the triggerer (#53009)
    
    * Let MSGraphAsyncOperator and MSGraphSensor start from the triggerer
    
    Both do all their requests in the triggerer, so the worker they start on
    only hands the first request over and that round trip is wasted. Since
    Airflow 3.3 the triggerer renders the templated fields of a task which
    starts from a trigger, which was what kept these two from using it.
    
    It is opt-in because the triggerer renders with a plain Jinja environment,
    without the user defined macros and filters or the native rendering of the
    Dag, and the task no longer waits for a pool slot. A task falls back to a
    worker when it cannot start from the trigger: on older Airflow versions,
    when an argument is an XComArg, a callable or a file-like object, which
    the triggerer cannot resolve or receive, and when it is mapped.
    
    * Keep tasks started from the triggerer consistent with retries, the sensor 
timeout and rendered values
    
    - A durable task starts on a worker, as its crash recovery runs in execute.
    - Starting from the triggerer needs Airflow 3.4: before, a task the 
scheduler
      deferred keeps the XComs of an earlier try, and the operator reads them 
back.
    - The operator and the sensor build their first trigger in one method, and 
the
      start arguments come from that trigger, so both paths send the same 
request
      and the sensor no longer passes its timeout as HTTP timeout.
    - The sensor counts its timeout from the start of the try when the first 
poll
      comes from the triggerer.
    - MSGraphTrigger takes a rendered flag: triggers deferred by a worker hold
      rendered values or values from a response, and the triggerer leaves them 
as
      they are instead of rendering them again.
---
 .../test_utils/operators/run_deferrable.py         |  32 ++-
 .../microsoft/azure/docs/operators/msgraph.rst     |  35 +++
 providers/microsoft/azure/docs/sensors/msgraph.rst |  19 ++
 .../providers/microsoft/azure/operators/msgraph.py | 102 ++++++--
 .../providers/microsoft/azure/sensors/msgraph.py   |  67 +++--
 .../providers/microsoft/azure/triggers/msgraph.py  |  14 +
 .../providers/microsoft/azure/version_compat.py    |   2 +
 .../unit/microsoft/azure/operators/test_msgraph.py | 287 ++++++++++++++++++++-
 .../unit/microsoft/azure/sensors/test_msgraph.py   | 201 ++++++++++++++-
 .../unit/microsoft/azure/triggers/test_msgraph.py  |  14 +
 10 files changed, 728 insertions(+), 45 deletions(-)

diff --git 
a/devel-common/src/tests_common/test_utils/operators/run_deferrable.py 
b/devel-common/src/tests_common/test_utils/operators/run_deferrable.py
index cdcc7817676..10919a1f2fe 100644
--- a/devel-common/src/tests_common/test_utils/operators/run_deferrable.py
+++ b/devel-common/src/tests_common/test_utils/operators/run_deferrable.py
@@ -18,6 +18,7 @@ from __future__ import annotations
 
 import asyncio
 from copy import deepcopy
+from importlib import import_module
 from typing import TYPE_CHECKING, Any
 
 from airflow.exceptions import TaskDeferred
@@ -45,14 +46,43 @@ def execute_operator(operator: Operator) -> tuple[Any, Any]:
     return asyncio.run(deferrable_operator(context, operator))
 
 
+def start_from_trigger(context, operator) -> TaskDeferred:
+    """
+    Defer an operator which starts from the triggerer, as the scheduler and 
the triggerer do.
+
+    The trigger is built from the arguments the operator declared when it was 
created, so before
+    any of its templated fields got rendered, and the triggerer then renders 
the fields the
+    trigger shares with the operator.
+    """
+    start_trigger_args = operator.start_trigger_args
+    trigger_kwargs = start_trigger_args.trigger_kwargs or {}
+    module_name, _, class_name = start_trigger_args.trigger_cls.rpartition(".")
+    trigger = getattr(import_module(module_name), class_name)(**trigger_kwargs)
+    trigger.template_fields = tuple(
+        field for field in operator.template_fields if field in trigger_kwargs 
and hasattr(trigger, field)
+    )
+    trigger.render_template_fields(context=context)
+    return TaskDeferred(
+        trigger=trigger,
+        method_name=start_trigger_args.next_method,
+        kwargs=start_trigger_args.next_kwargs,
+        timeout=start_trigger_args.timeout,
+    )
+
+
 async def deferrable_operator(context, operator):
     result = None
     triggered_events = []
     try:
+        if getattr(operator, "start_from_trigger", False):
+            deferred = start_from_trigger(context, operator)
+            # The worker only gets involved once the trigger fired, and 
renders the operator then.
+            operator.render_template_fields(context=context)
+            raise deferred
         operator.render_template_fields(context=context)
         result = operator.execute(context=context)
     except TaskDeferred as deferred:
-        task = deferred
+        task: TaskDeferred | None = deferred
 
         while task:
             events = await run_tigger(task.trigger)
diff --git a/providers/microsoft/azure/docs/operators/msgraph.rst 
b/providers/microsoft/azure/docs/operators/msgraph.rst
index 875d3df4772..67fc9ed0c59 100644
--- a/providers/microsoft/azure/docs/operators/msgraph.rst
+++ b/providers/microsoft/azure/docs/operators/msgraph.rst
@@ -95,6 +95,41 @@ 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.
 
+.. _howto/operator:MSGraphAsyncOperator:start_from_trigger:
+
+Starting from the triggerer
+~~~~~~~~~~~~~~~~~~~~~~~~~~~
+
+The operator does all its requests in the triggerer, so the only thing it does 
on a worker at the start
+is to hand the first request over. With ``start_from_trigger=True`` the 
scheduler defers the task itself
+and that first worker run is skipped. The worker gets involved once the 
response is there, to process it
+and to follow the pagination.
+
+.. code-block:: python
+
+    site_task = MSGraphAsyncOperator(
+        task_id="news_site",
+        conn_id="msgraph_api",
+        url="sites/{{ params.site }}",
+        start_from_trigger=True,
+    )
+
+This needs Airflow 3.4 or later, on older versions the task starts on a worker 
as it does without the
+argument. It also starts on a worker when:
+
+* an argument which is passed on to the trigger is an ``XComArg``, a callable 
or a file-like object,
+  as the triggerer cannot resolve or receive those;
+* the task is mapped with ``expand``;
+* ``durable=True`` is set, as a retry then resumes from the task state store 
on a worker.
+
+Keep the following in mind when you enable it:
+
+* The templated fields of the first request are rendered by the triggerer, 
which does not know the
+  ``user_defined_macros`` and ``user_defined_filters`` of the Dag and always 
renders strings, also when
+  ``render_template_as_native_obj`` is set.
+* The task goes straight from scheduled to deferred, so the first request does 
not wait for a pool
+  slot or for a worker.
+
 Cross-References
 ----------------
 
diff --git a/providers/microsoft/azure/docs/sensors/msgraph.rst 
b/providers/microsoft/azure/docs/sensors/msgraph.rst
index f5477dcd772..64f44a4ef9d 100644
--- a/providers/microsoft/azure/docs/sensors/msgraph.rst
+++ b/providers/microsoft/azure/docs/sensors/msgraph.rst
@@ -33,6 +33,25 @@ Below is an example of using this sensor to poll the status 
of a PowerBI workspa
     :start-after: [START howto_sensor_powerbi_scan_status]
     :end-before: [END howto_sensor_powerbi_scan_status]
 
+Starting from the triggerer
+^^^^^^^^^^^^^^^^^^^^^^^^^^^
+
+With ``start_from_trigger=True`` the scheduler defers the sensor itself, so 
the first poll is done without
+a worker being involved. The ``timeout`` of the sensor counts from the moment 
the scheduler deferred the
+sensor, so it covers that first poll and the ones after it. The requirements
+and limitations are the same as for the operator, see
+:ref:`howto/operator:MSGraphAsyncOperator:start_from_trigger`.
+
+.. code-block:: python
+
+    check_workspace_status_task = MSGraphSensor(
+        task_id="check_workspaces_status",
+        conn_id="powerbi",
+        url="myorg/admin/workspaces/scanStatus/{scanId}",
+        path_parameters={"scanId": "{{ params.scan_id }}"},
+        start_from_trigger=True,
+    )
+
 
 Reference
 ---------
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 12f1f0e1314..4b06ace3209 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
@@ -36,9 +36,11 @@ from airflow.providers.microsoft.azure.triggers.msgraph 
import (
     MSGraphTrigger,
     ResponseSerializer,
 )
-from airflow.providers.microsoft.azure.version_compat import AIRFLOW_V_3_3_PLUS
+from airflow.providers.microsoft.azure.version_compat import 
AIRFLOW_V_3_3_PLUS, AIRFLOW_V_3_4_PLUS
+from airflow.triggers.base import StartTriggerArgs
 
 if TYPE_CHECKING:
+    from datetime import timedelta
     from io import BytesIO
 
     from msgraph_core import APIVersion
@@ -64,6 +66,52 @@ class _PaginationCursor(TypedDict):
     pages: int
 
 
+def _is_plain_data(value: Any) -> bool:
+    if isinstance(value, dict):
+        return all(isinstance(key, str) and _is_plain_data(item) for key, item 
in value.items())
+    if isinstance(value, (list, tuple)):
+        return all(_is_plain_data(item) for item in value)
+    return value is None or isinstance(value, (str, int, float, bool))
+
+
+def build_start_trigger_args(
+    trigger: MSGraphTrigger, next_method: str, timeout: timedelta | None = None
+) -> StartTriggerArgs | None:
+    """
+    Build the arguments which start a task from the triggerer with the given 
trigger, when that is possible.
+
+    The trigger is the one the task would defer to on a worker, built from its 
arguments before they are
+    rendered, so both paths send the same first request.  The triggerer 
renders the templated fields of
+    that trigger itself, and it only gets what the serialized Dag holds: it 
never resolves an ``XComArg``,
+    and a callable or a file-like object cannot be serialized.  None is 
returned in those cases, and on
+    Airflow before 3.4, so the task starts on a worker instead.  Before 3.4 
the XComs of an earlier try
+    are not cleared for a task the scheduler deferred, and the operator would 
read them back.
+
+    :param trigger: The trigger which sends the first request.
+    :param next_method: The method of the task which handles the event of the 
trigger.
+    :param timeout: How long the trigger may run before the task times out 
(default is None).
+    """
+    if not AIRFLOW_V_3_4_PLUS:
+        return None
+
+    trigger_cls, trigger_kwargs = trigger.serialize()
+    trigger_kwargs["api_version"] = 
KiotaRequestAdapterHook.resolve_api_version_from_value(
+        trigger_kwargs["api_version"]
+    )
+    # This trigger is the one the triggerer renders, unlike the ones a worker 
defers afterwards.
+    trigger_kwargs["rendered"] = False
+
+    if not _is_plain_data(trigger_kwargs):
+        return None
+
+    return StartTriggerArgs(
+        trigger_cls=trigger_cls,
+        trigger_kwargs=trigger_kwargs,
+        next_method=next_method,
+        timeout=timeout,
+    )
+
+
 def default_event_handler(event: dict[Any, Any] | None = None, **context) -> 
Any:
     if event:
         if event.get("status") == "failure":
@@ -137,6 +185,10 @@ class MSGraphAsyncOperator(BaseOperator):
         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.
+    :param start_from_trigger: Start the task directly from the triggerer, 
without first running it on a
+        worker (default is False).  This requires Airflow 3.4 or later, and 
arguments which only hold plain
+        values: when an argument is an ``XComArg``, a callable or a file-like 
object, or when ``durable`` is
+        set, the task starts on a worker as usual.  The templated fields are 
then rendered by the triggerer.
     """
 
     # Durable execution implemented directly against task_state_store, without 
ResumableJobMixin.
@@ -175,6 +227,7 @@ class MSGraphAsyncOperator(BaseOperator):
         event_handler: Callable[[dict[Any, Any] | None, Context], Any] | None 
= None,
         serializer: type[ResponseSerializer] = ResponseSerializer,
         durable: bool = False,
+        start_from_trigger: bool = False,
         **kwargs: Any,
     ):
         super().__init__(**kwargs)
@@ -200,6 +253,14 @@ class MSGraphAsyncOperator(BaseOperator):
         self.event_handler = event_handler or default_event_handler
         self.serializer: ResponseSerializer = serializer()
         self.durable = durable
+        # A durable task resumes from the task state store in execute, which a 
task the scheduler
+        # deferred never runs.
+        self.start_trigger_args = (
+            build_start_trigger_args(self._build_trigger(), 
next_method=self.execute_complete.__name__)
+            if start_from_trigger and not durable
+            else None
+        )
+        self.start_from_trigger = self.start_trigger_args is not None
 
     def execute(self, context: Context) -> Any:
         if task_state_store := self.get_task_state_store(context):
@@ -225,24 +286,26 @@ class MSGraphAsyncOperator(BaseOperator):
                 "task_state_store not available in context, crash recovery is 
disabled for this run"
             )
 
-        self.defer(
-            trigger=MSGraphTrigger(
-                url=self.url,
-                response_type=self.response_type,
-                path_parameters=self.path_parameters,
-                url_template=self.url_template,
-                method=self.method,
-                query_parameters=self.query_parameters,
-                headers=self.headers,
-                data=self.data,
-                conn_id=self.conn_id,
-                timeout=self.timeout,
-                proxies=self.proxies,
-                scopes=self.scopes,
-                api_version=self.api_version,
-                serializer=type(self.serializer),
-            ),
-            method_name=self.execute_complete.__name__,
+        self.defer(trigger=self._build_trigger(), 
method_name=self.execute_complete.__name__)
+
+    def _build_trigger(self) -> MSGraphTrigger:
+        """Build the trigger which sends the first request, with the arguments 
as they are now."""
+        return MSGraphTrigger(
+            url=self.url,
+            response_type=self.response_type,
+            path_parameters=self.path_parameters,
+            url_template=self.url_template,
+            method=self.method,
+            query_parameters=self.query_parameters,
+            headers=self.headers,
+            data=self.data,
+            conn_id=self.conn_id,
+            timeout=self.timeout,
+            proxies=self.proxies,
+            scopes=self.scopes,
+            api_version=self.api_version,
+            serializer=type(self.serializer),
+            rendered=True,
         )
 
     def execute_complete(
@@ -532,6 +595,7 @@ class MSGraphAsyncOperator(BaseOperator):
                 api_version=self.api_version,
                 serializer=type(self.serializer),
                 pagination_link=True,
+                rendered=True,
             ),
             method_name=method_name,
             kwargs={"query_parameters": query_parameters},
diff --git 
a/providers/microsoft/azure/src/airflow/providers/microsoft/azure/sensors/msgraph.py
 
b/providers/microsoft/azure/src/airflow/providers/microsoft/azure/sensors/msgraph.py
index beada87b7a1..3b0dd5bad4a 100644
--- 
a/providers/microsoft/azure/src/airflow/providers/microsoft/azure/sensors/msgraph.py
+++ 
b/providers/microsoft/azure/src/airflow/providers/microsoft/azure/sensors/msgraph.py
@@ -29,7 +29,7 @@ from airflow.providers.common.compat.sdk import (
 )
 from airflow.providers.common.compat.standard.triggers import TimeDeltaTrigger
 from airflow.providers.microsoft.azure.hooks.msgraph import 
KiotaRequestAdapterHook
-from airflow.providers.microsoft.azure.operators.msgraph import 
execute_callable
+from airflow.providers.microsoft.azure.operators.msgraph import 
build_start_trigger_args, execute_callable
 from airflow.providers.microsoft.azure.triggers.msgraph import MSGraphTrigger, 
ResponseSerializer
 
 if TYPE_CHECKING:
@@ -62,6 +62,10 @@ class MSGraphSensor(BaseSensorOperator):
         `KiotaRequestAdapterHook` are bytes, then those will be base64 encoded 
into a string.
     :param serializer: Class which handles response serialization (default is 
ResponseSerializer).
         Bytes will be base64 encoded into a string, so it can be stored as an 
XCom.
+    :param start_from_trigger: Start the task directly from the triggerer, 
without first running it on a
+        worker (default is False).  This requires Airflow 3.4 or later, and 
arguments which only hold plain
+        values: when an argument is an ``XComArg``, a callable or a file-like 
object, the task starts on a
+        worker as usual.  The templated fields are then rendered by the 
triggerer.
     """
 
     template_fields: Sequence[str] = (
@@ -93,6 +97,7 @@ class MSGraphSensor(BaseSensorOperator):
         result_processor: Callable[[Any, Context], Any] = lambda result, 
**context: result,
         serializer: type[ResponseSerializer] = ResponseSerializer,
         retry_delay: timedelta | float = 60,
+        start_from_trigger: bool = False,
         **kwargs,
     ):
         super().__init__(retry_delay=retry_delay, **kwargs)
@@ -111,40 +116,62 @@ class MSGraphSensor(BaseSensorOperator):
         self.event_processor = event_processor
         self.result_processor = result_processor
         self.serializer = serializer()
+        # The scheduler defers the task itself here, and it only knows the 
timeout it is handed.
+        self.start_trigger_args = (
+            build_start_trigger_args(
+                self._build_trigger(),
+                next_method=self.execute_complete.__name__,
+                timeout=timedelta(seconds=self.timeout),
+            )
+            if start_from_trigger
+            else None
+        )
+        self.start_from_trigger = self.start_trigger_args is not None
 
     def execute(self, context: Context):
         self._defer_request(timezone.utcnow().timestamp(), self.timeout)
 
     def retry_execute(self, context: Context, sensor_started_at: float | None 
= None, **kwargs) -> Any:
-        started_at = self._started_at(sensor_started_at)
+        started_at = self._started_at(context, sensor_started_at)
         remaining = max(self._remaining_timeout(started_at), 
self.retry_delay.total_seconds())
         self._defer_request(started_at, remaining)
 
     def _defer_request(self, started_at: float, remaining: float) -> None:
         self.defer(
-            trigger=MSGraphTrigger(
-                url=self.url,
-                response_type=self.response_type,
-                path_parameters=self.path_parameters,
-                url_template=self.url_template,
-                method=self.method,
-                query_parameters=self.query_parameters,
-                headers=self.headers,
-                data=self.data,
-                conn_id=self.conn_id,
-                proxies=self.proxies,
-                scopes=self.scopes,
-                api_version=self.api_version,
-                serializer=type(self.serializer),
-            ),
+            trigger=self._build_trigger(),
             method_name=self.execute_complete.__name__,
             kwargs={"sensor_started_at": started_at},
             timeout=timedelta(seconds=remaining),
         )
 
+    def _build_trigger(self) -> MSGraphTrigger:
+        """Build the trigger which polls once, with the arguments as they are 
now."""
+        return MSGraphTrigger(
+            url=self.url,
+            response_type=self.response_type,
+            path_parameters=self.path_parameters,
+            url_template=self.url_template,
+            method=self.method,
+            query_parameters=self.query_parameters,
+            headers=self.headers,
+            data=self.data,
+            conn_id=self.conn_id,
+            proxies=self.proxies,
+            scopes=self.scopes,
+            api_version=self.api_version,
+            serializer=type(self.serializer),
+            rendered=True,
+        )
+
     @staticmethod
-    def _started_at(sensor_started_at: float | None) -> float:
-        return sensor_started_at if sensor_started_at is not None else 
timezone.utcnow().timestamp()
+    def _started_at(context: Context, sensor_started_at: float | None) -> 
float:
+        if sensor_started_at is not None:
+            return sensor_started_at
+        # The first poll of a task the scheduler deferred brings no 
sensor_started_at: the scheduler
+        # set the start of the try when it deferred the task.
+        if start_date := getattr(context.get("ti"), "start_date", None):
+            return start_date.timestamp()
+        return timezone.utcnow().timestamp()
 
     def _remaining_timeout(self, started_at: float) -> float:
         return self.timeout - max(timezone.utcnow().timestamp() - started_at, 
0)
@@ -198,7 +225,7 @@ class MSGraphSensor(BaseSensorOperator):
 
                     return result
 
-                started_at = self._started_at(sensor_started_at)
+                started_at = self._started_at(context, sensor_started_at)
                 remaining = self._remaining_timeout(started_at)
                 if remaining <= 0:
                     raise AirflowSensorTimeout(
diff --git 
a/providers/microsoft/azure/src/airflow/providers/microsoft/azure/triggers/msgraph.py
 
b/providers/microsoft/azure/src/airflow/providers/microsoft/azure/triggers/msgraph.py
index b6c32bb3f1d..f55c436fa90 100644
--- 
a/providers/microsoft/azure/src/airflow/providers/microsoft/azure/triggers/msgraph.py
+++ 
b/providers/microsoft/azure/src/airflow/providers/microsoft/azure/triggers/msgraph.py
@@ -40,9 +40,12 @@ from airflow.triggers.base import BaseTrigger, TriggerEvent
 if TYPE_CHECKING:
     from io import BytesIO
 
+    import jinja2
     from kiota_abstractions.request_adapter import RequestAdapter
     from msgraph_core import APIVersion
 
+    from airflow.sdk import Context
+
 
 class ResponseSerializer:
     """ResponseSerializer serializes the response as a string."""
@@ -98,6 +101,10 @@ class MSGraphTrigger(BaseTrigger):
     :param pagination_link: Whether `url` was taken from a pagination link of 
a previous response
         (default is False). When True, its host is verified against the 
configured Microsoft Graph
         endpoint before the request is made.
+    :param rendered: Whether the templated fields already hold their rendered 
values (default is False).
+        A task which starts from the triggerer gets every one of its triggers 
rendered by the triggerer,
+        also the ones a worker deferred with values it rendered itself or took 
from a response, such as
+        a pagination link. When True, the triggerer leaves the fields as they 
are.
     """
 
     def __init__(
@@ -117,6 +124,7 @@ class MSGraphTrigger(BaseTrigger):
         api_version: APIVersion | str | None = None,
         serializer: type[ResponseSerializer] = ResponseSerializer,
         pagination_link: bool = False,
+        rendered: bool = False,
     ):
         super().__init__()
         self.conn_id = conn_id
@@ -134,6 +142,7 @@ class MSGraphTrigger(BaseTrigger):
         self.data = data
         self.serializer: ResponseSerializer = self.resolve_type(serializer, 
default=ResponseSerializer)()
         self.pagination_link = pagination_link
+        self.rendered = rendered
 
     @classmethod
     def resolve_type(cls, value: str | type, default) -> type:
@@ -163,9 +172,14 @@ class MSGraphTrigger(BaseTrigger):
                 "data": self.data,
                 "response_type": self.response_type,
                 "pagination_link": self.pagination_link,
+                "rendered": self.rendered,
             },
         )
 
+    def render_template_fields(self, context: Context, jinja_env: 
jinja2.Environment | None = None) -> None:
+        if not self.rendered:
+            super().render_template_fields(context=context, 
jinja_env=jinja_env)
+
     def get_conn(self) -> RequestAdapter:
         """
         Initiate a new RequestAdapter connection.
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 8b5cdcf5cc4..67f7e4c3e50 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
@@ -35,9 +35,11 @@ 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)
+AIRFLOW_V_3_4_PLUS = get_base_airflow_version_tuple() >= (3, 4, 0)
 
 __all__ = [
     "AIRFLOW_V_3_0_PLUS",
     "AIRFLOW_V_3_1_PLUS",
     "AIRFLOW_V_3_3_PLUS",
+    "AIRFLOW_V_3_4_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 3d6ace80136..d44c63a97c4 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
@@ -20,14 +20,17 @@ import json
 import locale
 import warnings
 from base64 import b64encode
+from io import BytesIO
 from os.path import dirname
 from typing import Any
 from unittest import mock
 
 import pytest
+from msgraph_core import APIVersion
 
 from airflow.exceptions import AirflowProviderDeprecationWarning
-from airflow.providers.common.compat.sdk import AirflowException, Context, 
TaskDeferred, timezone
+from airflow.models.trigger import Trigger
+from airflow.providers.common.compat.sdk import DAG, AirflowException, 
Context, TaskDeferred, timezone
 from airflow.providers.microsoft.azure.exceptions import 
MSGraphPageNotInTaskStateStoreError
 from airflow.providers.microsoft.azure.operators.msgraph import (
     _COMPLETED_KEY,
@@ -36,12 +39,14 @@ from airflow.providers.microsoft.azure.operators.msgraph 
import (
     MSGraphAsyncOperator,
     execute_callable,
 )
+from airflow.providers.microsoft.azure.triggers.msgraph import MSGraphTrigger
 from airflow.triggers.base import TriggerEvent
+from airflow.utils.state import TaskInstanceState
 
 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 tests_common.test_utils.operators.run_deferrable import execute_operator, 
run_trigger
+from tests_common.test_utils.version_compat import AIRFLOW_V_3_3_PLUS, 
AIRFLOW_V_3_4_PLUS
 from unit.microsoft.azure.test_utils import (
     mock_json_response,
     mock_response,
@@ -783,3 +788,279 @@ class TestDurableMSGraphAsyncOperator:
         mock_log.return_value.warning.assert_called_once_with(
             "task_state_store not available in context, crash recovery is 
disabled for this run"
         )
+
+
[email protected](not AIRFLOW_V_3_4_PLUS, reason="A task starts from the 
triggerer from Airflow 3.4")
+class TestMSGraphAsyncOperatorStartFromTrigger:
+    def test_start_from_trigger_builds_the_trigger_arguments(self):
+        operator = MSGraphAsyncOperator(
+            task_id="user_messages",
+            conn_id="{{ params.conn_id }}",
+            url="users/{user_id}/messages",
+            response_type="bytes",
+            path_parameters={"user_id": "{{ params.user_id }}"},
+            url_template="{+baseurl}/users/{user_id}/messages{?%24top}",
+            method="POST",
+            query_parameters={"$top": 12},
+            headers={"ConsistencyLevel": "eventual"},
+            data={"subject": "{{ ds }}"},
+            timeout=30,
+            proxies={"https": "http://proxy:3128"},
+            scopes=["https://graph.microsoft.com/.default";],
+            api_version=APIVersion.beta,
+            start_from_trigger=True,
+        )
+
+        assert operator.start_from_trigger is True
+        assert operator.start_trigger_args.trigger_cls == (
+            "airflow.providers.microsoft.azure.triggers.msgraph.MSGraphTrigger"
+        )
+        assert operator.start_trigger_args.next_method == "execute_complete"
+        assert operator.start_trigger_args.next_kwargs is None
+        assert operator.start_trigger_args.timeout is None
+        assert operator.start_trigger_args.trigger_kwargs == {
+            "url": "users/{user_id}/messages",
+            "response_type": "bytes",
+            "path_parameters": {"user_id": "{{ params.user_id }}"},
+            "url_template": "{+baseurl}/users/{user_id}/messages{?%24top}",
+            "method": "POST",
+            "query_parameters": {"$top": 12},
+            "headers": {"ConsistencyLevel": "eventual"},
+            "data": {"subject": "{{ ds }}"},
+            "conn_id": "{{ params.conn_id }}",
+            "timeout": 30,
+            "proxies": {"https": "http://proxy:3128"},
+            "scopes": ["https://graph.microsoft.com/.default";],
+            "api_version": "beta",
+            "serializer": 
"airflow.providers.microsoft.azure.triggers.msgraph.ResponseSerializer",
+            "pagination_link": False,
+            "rendered": False,
+        }
+        # The enum itself would not survive the serialized Dag, its value does.
+        assert type(operator.start_trigger_args.trigger_kwargs["api_version"]) 
is str
+
+    def test_start_from_trigger_defers_the_same_trigger_as_execute(self):
+        operator = MSGraphAsyncOperator(
+            task_id="user_messages",
+            conn_id="msgraph_api",
+            url="users/{user_id}/messages",
+            path_parameters={"user_id": "{{ params.user_id }}"},
+            query_parameters={"$top": 12},
+            api_version=APIVersion.beta,
+            start_from_trigger=True,
+        )
+
+        with pytest.raises(TaskDeferred) as deferred:
+            operator.execute(context=Context())
+
+        trigger = MSGraphTrigger(**operator.start_trigger_args.trigger_kwargs)
+
+        trigger_cls, trigger_kwargs = deferred.value.trigger.serialize()
+        # The worker defers its trigger with rendered values, the triggerer 
renders the start trigger itself.
+        assert trigger_kwargs["rendered"] is True
+        assert trigger.serialize() == (trigger_cls, {**trigger_kwargs, 
"rendered": False})
+        assert operator.start_trigger_args.next_method == 
deferred.value.method_name
+
+    @pytest.mark.parametrize(
+        "arguments",
+        [
+            pytest.param(lambda upstream: {"url": upstream.output}, 
id="xcom-arg"),
+            pytest.param(
+                lambda upstream: {"path_parameters": {"user_id": 
upstream.output}}, id="nested-xcom-arg"
+            ),
+            pytest.param(
+                lambda upstream: {"query_parameters": {"$select": 
[upstream.output]}},
+                id="xcom-arg-in-list",
+            ),
+            pytest.param(
+                lambda upstream: {"path_parameters": lambda context, 
jinja_env: {"user_id": "me"}},
+                id="callable",
+            ),
+            pytest.param(lambda upstream: {"data": BytesIO(b"content")}, 
id="file-like-object"),
+        ],
+    )
+    def test_start_from_trigger_falls_back_to_the_worker(self, arguments):
+        with DAG(dag_id="msgraph_start_from_trigger", schedule=None):
+            upstream = MSGraphAsyncOperator(task_id="upstream", 
conn_id="msgraph_api", url="users")
+            operator = MSGraphAsyncOperator(
+                **{
+                    "task_id": "user_messages",
+                    "conn_id": "msgraph_api",
+                    "url": "users/{user_id}/messages",
+                    "start_from_trigger": True,
+                    **arguments(upstream),
+                }
+            )
+
+        assert operator.start_from_trigger is False
+        assert operator.start_trigger_args is None
+
+    
@mock.patch("airflow.providers.microsoft.azure.operators.msgraph.AIRFLOW_V_3_4_PLUS",
 False)
+    def test_start_from_trigger_needs_airflow_3_4(self):
+        # Before 3.4 the XComs of an earlier try are not cleared for a task 
the scheduler deferred.
+        operator = MSGraphAsyncOperator(
+            task_id="users", conn_id="msgraph_api", url="users", 
start_from_trigger=True
+        )
+
+        assert operator.start_from_trigger is False
+        assert operator.start_trigger_args is None
+
+    def test_durable_task_starts_on_a_worker(self):
+        operator = MSGraphAsyncOperator(
+            task_id="users", conn_id="msgraph_api", url="users", durable=True, 
start_from_trigger=True
+        )
+
+        assert operator.start_from_trigger is False
+        assert operator.start_trigger_args is None
+
+    def test_triggers_deferred_by_the_worker_are_not_rendered_again(self):
+        operator = MSGraphAsyncOperator(
+            task_id="users", conn_id="msgraph_api", url="users", 
start_from_trigger=True
+        )
+
+        with pytest.raises(TaskDeferred) as first_page:
+            operator.execute(context=Context())
+        with pytest.raises(TaskDeferred) as next_page:
+            operator.defer_to_next_link(
+                url="https://graph.microsoft.com/v1.0/users?$skiptoken={{ x 
}}",
+                query_parameters=None,
+                method_name=operator.execute_complete.__name__,
+            )
+
+        assert first_page.value.trigger.rendered is True
+        assert next_page.value.trigger.rendered is True
+
+    def test_start_trigger_args_are_not_shared_between_tasks(self):
+        users = MSGraphAsyncOperator(
+            task_id="users", conn_id="msgraph_api", url="users", 
start_from_trigger=True
+        )
+        groups = MSGraphAsyncOperator(
+            task_id="groups", conn_id="msgraph_api", url="groups", 
start_from_trigger=True
+        )
+        sites = MSGraphAsyncOperator(task_id="sites", conn_id="msgraph_api", 
url="sites")
+
+        assert users.start_trigger_args is not groups.start_trigger_args
+        assert users.start_trigger_args.trigger_kwargs["url"] == "users"
+        assert groups.start_trigger_args.trigger_kwargs["url"] == "groups"
+        assert sites.start_from_trigger is False
+        assert sites.start_trigger_args is None
+        assert MSGraphAsyncOperator.start_from_trigger is False
+        assert MSGraphAsyncOperator.start_trigger_args is None
+
+    def test_mapped_task_has_no_start_trigger_args(self):
+        with DAG(dag_id="msgraph_start_from_trigger", schedule=None):
+            mapped = MSGraphAsyncOperator.partial(
+                task_id="users", conn_id="msgraph_api", start_from_trigger=True
+            ).expand(url=["users", "groups"])
+
+        # The scheduler cannot expand the arguments of a mapped task, and 
without them it leaves
+        # the task to a worker.
+        assert mapped.start_trigger_args is None
+
+    @pytest.mark.db_test
+    @pytest.mark.need_serialized_dag
+    def test_scheduler_defers_the_task_to_the_triggerer(self, dag_maker, 
session):
+        with dag_maker(session=session):
+            users = MSGraphAsyncOperator(
+                task_id="users",
+                conn_id="msgraph_api",
+                url="users/{{ params.user_id }}",
+                query_parameters={"$top": 12},
+                start_from_trigger=True,
+            )
+            MSGraphAsyncOperator(
+                task_id="messages", conn_id="msgraph_api", url=users.output, 
start_from_trigger=True
+            )
+
+        dag_run = dag_maker.create_dagrun()
+        task_instances = {}
+        for task_id in ("users", "messages"):
+            task_instances[task_id] = dag_run.get_task_instance(task_id, 
session=session)
+            task_instances[task_id].task = dag_run.dag.get_task(task_id)
+
+        dag_run.schedule_tis(task_instances.values(), session=session)
+
+        assert task_instances["users"].state == TaskInstanceState.DEFERRED
+        assert task_instances["users"].next_method == "execute_complete"
+        assert task_instances["users"].trigger_timeout is None
+        trigger = session.get(Trigger, task_instances["users"].trigger_id)
+        assert trigger.classpath == 
"airflow.providers.microsoft.azure.triggers.msgraph.MSGraphTrigger"
+        assert trigger.kwargs == users.start_trigger_args.trigger_kwargs
+        # The task which takes its url from an XCom still starts on a worker.
+        session.refresh(task_instances["messages"])
+        assert task_instances["messages"].state == TaskInstanceState.SCHEDULED
+        assert task_instances["messages"].trigger_id is None
+
+    @pytest.mark.db_test
+    def test_triggerer_renders_the_templated_fields(self, 
create_task_instance):
+        users = load_json_from_resources(dirname(__file__), "..", "resources", 
"users.json")
+        users.pop("@odata.nextLink")
+        operator = MSGraphAsyncOperator(
+            task_id="user_messages",
+            conn_id="{{ conn_id }}",
+            url="users/{user_id}/messages",
+            path_parameters={"user_id": "{{ user_id }}"},
+            query_parameters={"$top": "{{ top }}"},
+            headers={"ConsistencyLevel": "{{ consistency }}"},
+            start_from_trigger=True,
+        )
+        task_instance = create_task_instance(
+            task=operator, start_from_trigger=True, 
start_trigger_args=operator.start_trigger_args
+        )
+        trigger = MSGraphTrigger(**operator.start_trigger_args.trigger_kwargs)
+
+        trigger.task_instance = task_instance
+
+        assert trigger.template_fields == MSGraphAsyncOperator.template_fields
+
+        trigger.render_template_fields(
+            context={"conn_id": "msgraph_api", "user_id": "me", "top": 12, 
"consistency": "eventual"}
+        )
+
+        assert trigger.conn_id == "msgraph_api"
+        assert trigger.path_parameters == {"user_id": "me"}
+        assert trigger.query_parameters == {"$top": "12"}
+        assert trigger.headers == {"ConsistencyLevel": "eventual"}
+
+        with patch_hook_and_request_adapter(mock_json_response(200, users)) as 
(
+            *_,
+            mock_get_http_response,
+        ):
+            events = run_trigger(trigger)
+
+        request = mock_get_http_response.call_args.args[0]
+
+        assert request.url == "users/me/messages?%24top=12"
+        assert request.headers.try_get("ConsistencyLevel") == {"eventual"}
+        assert events[0].payload["status"] == "success"
+        assert events[0].payload["response"] == json.dumps(users)
+
+    def test_execute_when_started_from_the_trigger(self):
+        users = load_json_from_resources(dirname(__file__), "..", "resources", 
"users.json")
+        next_users = load_json_from_resources(dirname(__file__), "..", 
"resources", "next_users.json")
+        response = mock_json_response(200, users, next_users)
+
+        with (
+            patch_hook_and_request_adapter(response) as (*_, 
mock_get_http_response),
+            mock.patch.object(MSGraphAsyncOperator, "execute", autospec=True) 
as mock_execute,
+        ):
+            operator = MSGraphAsyncOperator(
+                task_id="users",
+                conn_id="msgraph_api",
+                url="{{ ti.task_id }}",
+                result_processor=lambda result, **context: result.get("value"),
+                start_from_trigger=True,
+            )
+
+            results, events = execute_operator(operator)
+
+        # The first page is requested by the trigger the scheduler created, 
with the url it rendered
+        # itself, and the worker only takes over from there to follow the 
pagination.
+        mock_execute.assert_not_called()
+        assert mock_get_http_response.call_args_list[0].args[0].url == "users"
+        assert mock_get_http_response.call_count == 2
+        assert results == users.get("value") + next_users.get("value")
+        assert [event.payload["response"] for event in events] == [
+            json.dumps(users),
+            json.dumps(next_users),
+        ]
diff --git 
a/providers/microsoft/azure/tests/unit/microsoft/azure/sensors/test_msgraph.py 
b/providers/microsoft/azure/tests/unit/microsoft/azure/sensors/test_msgraph.py
index 2dd951c1e99..1c469ba4d8d 100644
--- 
a/providers/microsoft/azure/tests/unit/microsoft/azure/sensors/test_msgraph.py
+++ 
b/providers/microsoft/azure/tests/unit/microsoft/azure/sensors/test_msgraph.py
@@ -19,21 +19,32 @@ from __future__ import annotations
 import json
 from datetime import UTC, datetime, timedelta
 from os.path import dirname
+from types import SimpleNamespace
 from unittest.mock import patch
 
 import pytest
 import time_machine
 
 from airflow.exceptions import AirflowProviderDeprecationWarning
-from airflow.providers.common.compat.sdk import AirflowSensorTimeout, 
AirflowSkipException, TaskDeferred
+from airflow.models.trigger import Trigger
+from airflow.providers.common.compat.sdk import (
+    DAG,
+    AirflowSensorTimeout,
+    AirflowSkipException,
+    Context,
+    TaskDeferred,
+    timezone,
+)
 from airflow.providers.common.compat.standard.triggers import TimeDeltaTrigger
+from airflow.providers.microsoft.azure.operators.msgraph import 
MSGraphAsyncOperator
 from airflow.providers.microsoft.azure.sensors.msgraph import MSGraphSensor
 from airflow.providers.microsoft.azure.triggers.msgraph import MSGraphTrigger
 from airflow.triggers.base import TriggerEvent
+from airflow.utils.state import TaskInstanceState
 
 from tests_common.test_utils.file_loading import load_json_from_resources
 from tests_common.test_utils.operators.run_deferrable import execute_operator
-from tests_common.test_utils.version_compat import AIRFLOW_V_3_1_PLUS
+from tests_common.test_utils.version_compat import AIRFLOW_V_3_1_PLUS, 
AIRFLOW_V_3_4_PLUS
 from unit.microsoft.azure.test_utils import mock_json_response, 
patch_hook_and_request_adapter
 
 T0 = datetime(2026, 1, 1, tzinfo=UTC)
@@ -268,6 +279,20 @@ class TestMSGraphSensor:
                 "execute_complete", {"event": NOT_DONE, "sensor_started_at": 
T0.timestamp()}, {}
             )
 
+    def 
test_first_poll_from_the_triggerer_counts_from_the_start_of_the_try(self):
+        sensor = _sensor()
+        task_instance = SimpleNamespace(start_date=T0)
+
+        with _at(50), pytest.raises(TaskDeferred) as exc:
+            sensor.execute_complete(context={"ti": task_instance}, 
event=NOT_DONE)
+
+        assert exc.value.kwargs == {"sensor_started_at": T0.timestamp()}
+        assert exc.value.trigger.moment == T0 + timedelta(seconds=80)
+
+        # Counted from the start of the try, the 100 seconds are over, not 
from the first event.
+        with _at(150), pytest.raises(AirflowSensorTimeout):
+            sensor.execute_complete(context={"ti": task_instance}, 
event=NOT_DONE)
+
     def test_missing_started_at_falls_back_to_now(self):
         sensor = _sensor()
 
@@ -292,3 +317,175 @@ class TestMSGraphSensor:
             )
 
         assert exc.value.timeout == timedelta(seconds=100)
+
+
[email protected](not AIRFLOW_V_3_4_PLUS, reason="A task starts from the 
triggerer from Airflow 3.4")
+class TestMSGraphSensorStartFromTrigger:
+    def test_start_from_trigger_builds_the_trigger_arguments(self):
+        sensor = MSGraphSensor(
+            task_id="check_workspaces_status",
+            conn_id="powerbi",
+            url="myorg/admin/workspaces/scanStatus/{scanId}",
+            path_parameters={"scanId": "{{ params.scan_id }}"},
+            timeout=10,
+            start_from_trigger=True,
+        )
+
+        assert sensor.start_from_trigger is True
+        assert sensor.start_trigger_args.trigger_cls == (
+            "airflow.providers.microsoft.azure.triggers.msgraph.MSGraphTrigger"
+        )
+        assert sensor.start_trigger_args.next_method == "execute_complete"
+        assert sensor.start_trigger_args.timeout == timedelta(seconds=10)
+        assert sensor.start_trigger_args.trigger_kwargs == {
+            "url": "myorg/admin/workspaces/scanStatus/{scanId}",
+            "response_type": None,
+            "path_parameters": {"scanId": "{{ params.scan_id }}"},
+            "url_template": None,
+            "method": "GET",
+            "query_parameters": None,
+            "headers": None,
+            "data": None,
+            "conn_id": "powerbi",
+            "timeout": None,
+            "proxies": None,
+            "scopes": None,
+            "api_version": None,
+            "serializer": 
"airflow.providers.microsoft.azure.triggers.msgraph.ResponseSerializer",
+            "pagination_link": False,
+            "rendered": False,
+        }
+
+    def test_start_from_trigger_defers_the_same_trigger_as_execute(self):
+        sensor = MSGraphSensor(
+            task_id="check_workspaces_status",
+            conn_id="powerbi",
+            url="myorg/admin/workspaces/scanStatus/{scanId}",
+            path_parameters={"scanId": "{{ params.scan_id }}"},
+            timeout=10,
+            start_from_trigger=True,
+        )
+
+        with pytest.raises(TaskDeferred) as deferred:
+            sensor.execute(context=Context())
+
+        trigger = MSGraphTrigger(**sensor.start_trigger_args.trigger_kwargs)
+
+        trigger_cls, trigger_kwargs = deferred.value.trigger.serialize()
+        # The worker defers its trigger with rendered values, the triggerer 
renders the start trigger itself.
+        assert trigger_kwargs["rendered"] is True
+        assert trigger.serialize() == (trigger_cls, {**trigger_kwargs, 
"rendered": False})
+        assert sensor.start_trigger_args.next_method == 
deferred.value.method_name
+
+    def test_start_from_trigger_falls_back_to_the_worker(self):
+        with DAG(dag_id="msgraph_start_from_trigger", schedule=None):
+            scan = MSGraphAsyncOperator(task_id="scan", conn_id="powerbi", 
url="myorg/admin/workspaces")
+            sensor = MSGraphSensor(
+                task_id="check_workspaces_status",
+                conn_id="powerbi",
+                url="myorg/admin/workspaces/scanStatus/{scanId}",
+                path_parameters={"scanId": scan.output},
+                start_from_trigger=True,
+            )
+
+        assert sensor.start_from_trigger is False
+        assert sensor.start_trigger_args is None
+
+    def test_start_trigger_args_are_not_shared_between_tasks(self):
+        first = MSGraphSensor(
+            task_id="first", conn_id="powerbi", url="first", timeout=10, 
start_from_trigger=True
+        )
+        second = MSGraphSensor(
+            task_id="second", conn_id="powerbi", url="second", timeout=20, 
start_from_trigger=True
+        )
+        third = MSGraphSensor(task_id="third", conn_id="powerbi", url="third")
+
+        assert first.start_trigger_args is not second.start_trigger_args
+        assert first.start_trigger_args.trigger_kwargs["url"] == "first"
+        assert first.start_trigger_args.timeout == timedelta(seconds=10)
+        assert second.start_trigger_args.trigger_kwargs["url"] == "second"
+        assert second.start_trigger_args.timeout == timedelta(seconds=20)
+        assert third.start_from_trigger is False
+        assert third.start_trigger_args is None
+        assert MSGraphSensor.start_from_trigger is False
+        assert MSGraphSensor.start_trigger_args is None
+
+    def test_mapped_task_has_no_start_trigger_args(self):
+        with DAG(dag_id="msgraph_start_from_trigger", schedule=None):
+            mapped = MSGraphSensor.partial(
+                task_id="check_workspaces_status",
+                conn_id="powerbi",
+                url="myorg/admin/workspaces/scanStatus/{scanId}",
+                start_from_trigger=True,
+            ).expand(path_parameters=[{"scanId": "first"}, {"scanId": 
"second"}])
+
+        # The scheduler cannot expand the arguments of a mapped task, and 
without them it leaves
+        # the task to a worker.
+        assert mapped.start_trigger_args is None
+
+    @pytest.mark.db_test
+    @pytest.mark.need_serialized_dag
+    def test_scheduler_defers_the_task_with_the_sensor_timeout(self, 
dag_maker, session, time_machine):
+        now = timezone.datetime(2026, 10, 5, 12)
+        time_machine.move_to(now, tick=False)
+
+        with dag_maker(session=session):
+            sensor = MSGraphSensor(
+                task_id="check_workspaces_status",
+                conn_id="powerbi",
+                url="myorg/admin/workspaces/scanStatus/{scanId}",
+                path_parameters={"scanId": "{{ params.scan_id }}"},
+                timeout=10,
+                start_from_trigger=True,
+            )
+
+        dag_run = dag_maker.create_dagrun()
+        task_instance = dag_run.get_task_instance("check_workspaces_status", 
session=session)
+        task_instance.task = dag_run.dag.get_task("check_workspaces_status")
+
+        dag_run.schedule_tis((task_instance,), session=session)
+
+        assert task_instance.state == TaskInstanceState.DEFERRED
+        assert task_instance.next_method == "execute_complete"
+        assert task_instance.trigger_timeout == now + timedelta(seconds=10)
+        trigger = session.get(Trigger, task_instance.trigger_id)
+        assert trigger.classpath == 
"airflow.providers.microsoft.azure.triggers.msgraph.MSGraphTrigger"
+        assert trigger.kwargs == sensor.start_trigger_args.trigger_kwargs
+
+    def test_execute_when_started_from_the_trigger(self):
+        status = load_json_from_resources(dirname(__file__), "..", 
"resources", "status.json")
+        response = mock_json_response(200, *status)
+
+        with (
+            patch_hook_and_request_adapter(response) as (*_, 
mock_get_http_response),
+            patch.object(MSGraphSensor, "execute", autospec=True) as 
mock_execute,
+            patch.object(
+                MSGraphSensor, "retry_execute", autospec=True, 
side_effect=MSGraphSensor.retry_execute
+            ) as mock_retry_execute,
+        ):
+            sensor = MSGraphSensor(
+                task_id="check_workspaces_status",
+                conn_id="powerbi",
+                url="myorg/admin/workspaces/scanStatus/{scanId}",
+                path_parameters={"scanId": "{{ ti.task_id }}"},
+                result_processor=lambda result, **context: result["id"],
+                retry_delay=1,
+                timeout=5,
+                start_from_trigger=True,
+            )
+
+            results, events = execute_operator(sensor)
+
+        # The first poll comes from the trigger the scheduler created, with 
the path parameters it
+        # rendered itself, so execute never runs and retry_execute does the 
poll after the retry delay.
+        mock_execute.assert_not_called()
+        mock_retry_execute.assert_called_once()
+        assert [call.args[0].url for call in 
mock_get_http_response.call_args_list] == [
+            "myorg/admin/workspaces/scanStatus/check_workspaces_status",
+            "myorg/admin/workspaces/scanStatus/check_workspaces_status",
+        ]
+        assert results == "0a1b1bf3-37de-48f7-9863-ed4cda97a9ef"
+        assert len(events) == 3
+        assert events[0].payload["response"] == json.dumps(status[0])
+        assert isinstance(events[1].payload, datetime)
+        assert events[2].payload["response"] == json.dumps(status[1])
diff --git 
a/providers/microsoft/azure/tests/unit/microsoft/azure/triggers/test_msgraph.py 
b/providers/microsoft/azure/tests/unit/microsoft/azure/triggers/test_msgraph.py
index 7ea42c7df49..02d1ee52176 100644
--- 
a/providers/microsoft/azure/tests/unit/microsoft/azure/triggers/test_msgraph.py
+++ 
b/providers/microsoft/azure/tests/unit/microsoft/azure/triggers/test_msgraph.py
@@ -38,6 +38,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.operators.run_deferrable import run_trigger
+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,
@@ -149,6 +150,7 @@ class TestMSGraphTrigger:
                 "api_version": APIVersion.v1.value,
                 "serializer": 
f"{ResponseSerializer.__module__}.{ResponseSerializer.__name__}",
                 "pagination_link": False,
+                "rendered": False,
             }
 
     def test_get_conn(self):
@@ -165,6 +167,18 @@ class TestMSGraphTrigger:
             assert actual.base_url == "https://graph.microsoft.com/v1.0/";
 
 
[email protected](not AIRFLOW_V_3_3_PLUS, reason="The triggerer renders 
templated fields from Airflow 3.3")
+class TestMSGraphTriggerRendering:
+    @pytest.mark.parametrize(("rendered", "expected"), [(False, "users/me"), 
(True, "users/{{ user_id }}")])
+    def test_render_template_fields(self, rendered, expected):
+        trigger = MSGraphTrigger(url="users/{{ user_id }}", rendered=rendered)
+        trigger.template_fields = ("url",)
+
+        trigger.render_template_fields(context={"user_id": "me"})
+
+        assert trigger.url == expected
+
+
 class TestResponseSerializer:
     def test_serialize_when_bytes_then_base64_encoded(self):
         response = load_file_from_resources(

Reply via email to