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

dabla pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/airflow.git


The following commit(s) were added to refs/heads/main by this push:
     new 54171ff7cc8 Fix MSGraphSensor timeout being reset on every re-defer 
(#75774)
54171ff7cc8 is described below

commit 54171ff7cc8e532f4488cce74aacbc8d3cd28027
Author: Mathieu Monet <[email protected]>
AuthorDate: Wed Oct 28 17:53:51 2026 +0100

    Fix MSGraphSensor timeout being reset on every re-defer (#75774)
    
    * Fix MSGraphSensor timeout being reset on every re-defer
    
    A defer timeout becomes an absolute deadline of now + timeout, and
    MSGraphSensor re-deferred with the full sensor timeout between polls
    (while the request defer carried none), so the sensor never timed out.
    
    Carry the first execute's start time in next_kwargs, give each request
    defer the remaining time, and raise AirflowSensorTimeout after a
    not-done poll once the timeout has elapsed, so soft_fail and never_fail
    apply as for core sensors.
    
    Stop passing the sensor timeout to MSGraphTrigger, where it was used as
    the HTTP request timeout.
    
    Co-Authored-By: Claude Opus 5.5 <[email protected]>
    
    * Keep a deadline on the MSGraphSensor sleep defer
    
    Give the TimeDeltaTrigger sleep a defer timeout of the remaining time
    plus retry_delay, so a lost sleep trigger still ends in a sensor
    timeout. Drop the unreachable timeout=None branches and use started_at
    consistently.
    
    Co-Authored-By: Claude Opus 5.5 <[email protected]>
    
    ---------
    
    Co-authored-by: Claude Opus 5.5 <[email protected]>
---
 .../providers/microsoft/azure/sensors/msgraph.py   |  44 ++++--
 .../unit/microsoft/azure/sensors/test_msgraph.py   | 161 +++++++++++++++++++--
 2 files changed, 181 insertions(+), 24 deletions(-)

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 103fb1d1b45..beada87b7a1 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
@@ -21,7 +21,12 @@ from collections.abc import Callable, Sequence
 from datetime import timedelta
 from typing import TYPE_CHECKING, Any
 
-from airflow.providers.common.compat.sdk import AirflowException, 
BaseSensorOperator
+from airflow.providers.common.compat.sdk import (
+    AirflowException,
+    AirflowSensorTimeout,
+    BaseSensorOperator,
+    timezone,
+)
 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
@@ -108,6 +113,14 @@ class MSGraphSensor(BaseSensorOperator):
         self.serializer = serializer()
 
     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)
+        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,
@@ -119,26 +132,28 @@ class MSGraphSensor(BaseSensorOperator):
                 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__,
+            kwargs={"sensor_started_at": started_at},
+            timeout=timedelta(seconds=remaining),
         )
 
-    def retry_execute(
-        self,
-        context: Context,
-        **kwargs,
-    ) -> Any:
-        self.execute(context=context)
+    @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 _remaining_timeout(self, started_at: float) -> float:
+        return self.timeout - max(timezone.utcnow().timestamp() - started_at, 
0)
 
     def execute_complete(
         self,
         context: Context,
         event: dict[Any, Any] | None = None,
+        sensor_started_at: float | None = None,
     ) -> Any:
         """
         Execute callback when MSGraphSensor finishes execution.
@@ -183,11 +198,18 @@ class MSGraphSensor(BaseSensorOperator):
 
                     return result
 
-                # Re-defer with timeout so Airflow enforces the sensor timeout 
natively
+                started_at = self._started_at(sensor_started_at)
+                remaining = self._remaining_timeout(started_at)
+                if remaining <= 0:
+                    raise AirflowSensorTimeout(
+                        f"Sensor has timed out; run duration of {self.timeout 
- remaining} seconds "
+                        f"exceeds the specified timeout of {self.timeout}."
+                    )
                 self.defer(
-                    trigger=TimeDeltaTrigger(self.retry_delay),
+                    trigger=TimeDeltaTrigger(min(self.retry_delay, 
timedelta(seconds=remaining))),
                     method_name=self.retry_execute.__name__,
-                    timeout=timedelta(seconds=self.timeout) if self.timeout is 
not None else None,
+                    kwargs={"sensor_started_at": started_at},
+                    timeout=timedelta(seconds=remaining) + self.retry_delay,
                 )
 
         return None
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 66f439ba722..2dd951c1e99 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
@@ -17,20 +17,48 @@
 from __future__ import annotations
 
 import json
-from datetime import datetime, timedelta
+from datetime import UTC, datetime, timedelta
 from os.path import dirname
 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.providers.common.compat.standard.triggers import TimeDeltaTrigger
 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 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 unit.microsoft.azure.test_utils import mock_json_response, 
patch_hook_and_request_adapter
 
+T0 = datetime(2026, 1, 1, tzinfo=UTC)
+NOT_DONE = {"status": "success", "type": "builtins.dict", "response": 
json.dumps({"status": "Running"})}
+DONE = {
+    "status": "success",
+    "type": "builtins.dict",
+    "response": json.dumps({"status": "Succeeded", "id": "x"}),
+}
+
+
+def _sensor(**kwargs) -> MSGraphSensor:
+    return MSGraphSensor(
+        task_id="check",
+        conn_id="powerbi",
+        url="myorg/admin/workspaces/scanStatus/{scanId}",
+        timeout=100,
+        retry_delay=30,
+        **kwargs,
+    )
+
+
+def _at(seconds: float):
+    return time_machine.travel(T0 + timedelta(seconds=seconds), tick=False)
+
 
 class TestMSGraphSensor:
     def test_execute_with_result_processor_with_old_signature(self):
@@ -143,17 +171,124 @@ class TestMSGraphSensor:
         for template_field in MSGraphSensor.template_fields:
             getattr(sensor, template_field)
 
-    def test_execute_complete_passes_timeout_to_defer(self):
-        sensor = MSGraphSensor(
-            task_id="check_timeout",
-            conn_id="powerbi",
-            url="myorg/admin/workspaces/scanStatus/{scanId}",
-            timeout=10,
-        )
+    def test_execute_defers_with_timeout_and_started_at(self):
+        sensor = _sensor()
+
+        with _at(0), pytest.raises(TaskDeferred) as exc:
+            sensor.execute(context={})
+
+        assert isinstance(exc.value.trigger, MSGraphTrigger)
+        assert exc.value.trigger.timeout is None
+        assert exc.value.method_name == "execute_complete"
+        assert exc.value.timeout == timedelta(seconds=100)
+        assert exc.value.kwargs == {"sensor_started_at": T0.timestamp()}
+
+    def test_execute_complete_redefers_with_capped_sleep_and_started_at(self):
+        sensor = _sensor()
+
+        with _at(10), pytest.raises(TaskDeferred) as exc:
+            sensor.execute_complete(context={}, event=NOT_DONE, 
sensor_started_at=T0.timestamp())
+
+        assert isinstance(exc.value.trigger, TimeDeltaTrigger)
+        assert exc.value.trigger.moment == T0 + timedelta(seconds=40)
+        assert exc.value.method_name == "retry_execute"
+        assert exc.value.timeout == timedelta(seconds=120)
+        assert exc.value.kwargs == {"sensor_started_at": T0.timestamp()}
+
+    @pytest.mark.parametrize("elapsed", [10, 40, 70])
+    def test_retry_execute_deadline_does_not_slide(self, elapsed):
+        sensor = _sensor()
+
+        with _at(elapsed), pytest.raises(TaskDeferred) as exc:
+            sensor.retry_execute(context={}, event=T0, 
sensor_started_at=T0.timestamp())
+
+        assert isinstance(exc.value.trigger, MSGraphTrigger)
+        assert exc.value.method_name == "execute_complete"
+        assert exc.value.timeout == timedelta(seconds=100 - elapsed)
+        assert exc.value.kwargs == {"sensor_started_at": T0.timestamp()}
+
+    def test_sleep_capped_to_remaining(self):
+        sensor = _sensor()
+
+        with _at(90), pytest.raises(TaskDeferred) as exc:
+            sensor.execute_complete(context={}, event=NOT_DONE, 
sensor_started_at=T0.timestamp())
 
-        with patch.object(sensor, "defer") as mock_defer:
-            sensor.execute_complete(
-                context={}, event={"status": "success", "response": 
json.dumps({"status": "running"})}
+        assert exc.value.trigger.moment == T0 + timedelta(seconds=100)
+        assert exc.value.timeout == timedelta(seconds=40)
+
+    @pytest.mark.parametrize("elapsed", [100, 150])
+    def test_past_deadline_raises_sensor_timeout(self, elapsed):
+        sensor = _sensor()
+
+        with (
+            _at(elapsed),
+            patch.object(sensor, "defer") as mock_defer,
+            pytest.raises(AirflowSensorTimeout, match="Sensor has timed out"),
+        ):
+            sensor.execute_complete(context={}, event=NOT_DONE, 
sensor_started_at=T0.timestamp())
+
+        mock_defer.assert_not_called()
+
+    @pytest.mark.parametrize("elapsed", [80, 99.7, 100, 150])
+    def test_retry_execute_near_or_past_deadline_polls_once_more(self, 
elapsed):
+        sensor = _sensor()
+
+        with _at(elapsed), pytest.raises(TaskDeferred) as exc:
+            sensor.retry_execute(context={}, event=T0, 
sensor_started_at=T0.timestamp())
+
+        assert isinstance(exc.value.trigger, MSGraphTrigger)
+        assert exc.value.method_name == "execute_complete"
+        assert exc.value.timeout == timedelta(seconds=30)
+        assert exc.value.kwargs == {"sensor_started_at": T0.timestamp()}
+
+    def test_done_after_deadline_returns_result(self):
+        sensor = _sensor(result_processor=lambda result, **context: 
result["id"])
+
+        with _at(150):
+            assert sensor.execute_complete(context={}, event=DONE, 
sensor_started_at=T0.timestamp()) == "x"
+
+    @pytest.mark.parametrize(
+        ("flags", "expected"),
+        [
+            pytest.param({}, AirflowSensorTimeout, id="no_flag"),
+            pytest.param({"soft_fail": True}, AirflowSkipException, 
id="soft_fail"),
+            pytest.param(
+                {"never_fail": True},
+                AirflowSkipException,
+                id="never_fail",
+                marks=pytest.mark.skipif(not AIRFLOW_V_3_1_PLUS, 
reason="never_fail on resume is 3.1+"),
+            ),
+        ],
+    )
+    def test_timeout_via_resume_execution(self, flags, expected):
+        sensor = _sensor(**flags)
+
+        with _at(150), pytest.raises(expected):
+            sensor.resume_execution(
+                "execute_complete", {"event": NOT_DONE, "sensor_started_at": 
T0.timestamp()}, {}
             )
-            mock_defer.assert_called_once()
-            assert mock_defer.call_args.kwargs["timeout"] == 
timedelta(seconds=10)
+
+    def test_missing_started_at_falls_back_to_now(self):
+        sensor = _sensor()
+
+        with _at(500), pytest.raises(TaskDeferred) as exc:
+            sensor.retry_execute(context={}, event=T0)
+
+        assert exc.value.timeout == timedelta(seconds=100)
+        assert exc.value.kwargs == {"sensor_started_at": (T0 + 
timedelta(seconds=500)).timestamp()}
+
+        with _at(500), pytest.raises(TaskDeferred) as exc:
+            sensor.execute_complete(context={}, event=NOT_DONE)
+
+        assert exc.value.trigger.moment == T0 + timedelta(seconds=530)
+        assert exc.value.kwargs == {"sensor_started_at": (T0 + 
timedelta(seconds=500)).timestamp()}
+
+    def test_future_started_at_clamped(self):
+        sensor = _sensor()
+
+        with _at(0), pytest.raises(TaskDeferred) as exc:
+            sensor.retry_execute(
+                context={}, event=T0, sensor_started_at=(T0 + 
timedelta(seconds=10)).timestamp()
+            )
+
+        assert exc.value.timeout == timedelta(seconds=100)

Reply via email to