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 841d51e3636 Enforce execution_timeout in deferrable 
DataprocSubmitJobOperator (#72030)
841d51e3636 is described below

commit 841d51e36367eef41ef4b6096a100dc34062ad6d
Author: Noel <[email protected]>
AuthorDate: Sat Oct 31 13:02:54 2026 -0300

    Enforce execution_timeout in deferrable DataprocSubmitJobOperator (#72030)
    
    * Enforce execution_timeout for deferred Dataproc submit jobs
    
    DataprocSubmitJobOperator ignored execution_timeout once it deferred,
    so a stuck job could run forever. The operator now derives an
    absolute deadline from ti.start_date + execution_timeout, passes it
    to DataprocSubmitTrigger as execution_deadline, and also passes
    timeout= to defer() as a framework-level backstop.
    
    DataprocSubmitTrigger cancels the Dataproc job once the deadline
    passes (unless cancel_on_kill is False) and emits a TIMED_OUT event;
    execute_complete raises AirflowException on that event.
    
    Add tests for both the trigger and the operator, and record the new
    AirflowException raise in the known-exceptions allowlist.
    
    * Address review: KPO-style deadline handling for Dataproc trigger
    
    Check the execution deadline before polling the job, so a deferral that
    wakes past the deadline cancels the job without first waiting on
    get_job() and its retries. Pad the framework defer timeout by
    max(60, 2 * polling_interval_seconds), matching KubernetesPodOperator,
    so the trigger's own timeout handling wins the race.
    
    Raise AirflowTaskTimeout instead of AirflowException on the TIMED_OUT
    event, and freeze time in the operator test to assert the exact defer
    timeout.
    
    * Bound Dataproc trigger polling by the execution deadline
    
    A get_job() call started just before the deadline could keep retrying
    past it, and the polling sleep could overshoot it by a full interval,
    letting the framework defer timeout fire first and orphan the job.
    Wrap get_job() in asyncio.wait_for() with the remaining time and cap
    the sleep at the deadline.
    
    ---------
    
    Co-authored-by: Noel <[email protected]>
    Co-authored-by: Shahar Epstein <[email protected]>
---
 .../providers/google/cloud/operators/dataproc.py   | 24 +++++-
 .../providers/google/cloud/triggers/dataproc.py    | 39 ++++++++-
 .../unit/google/cloud/operators/test_dataproc.py   | 45 ++++++++++
 .../unit/google/cloud/triggers/test_dataproc.py    | 95 ++++++++++++++++++++++
 4 files changed, 198 insertions(+), 5 deletions(-)

diff --git 
a/providers/google/src/airflow/providers/google/cloud/operators/dataproc.py 
b/providers/google/src/airflow/providers/google/cloud/operators/dataproc.py
index 88059d97f45..3f2e0e06be3 100644
--- a/providers/google/src/airflow/providers/google/cloud/operators/dataproc.py
+++ b/providers/google/src/airflow/providers/google/cloud/operators/dataproc.py
@@ -36,7 +36,7 @@ from google.api_core.retry import Retry, 
exponential_sleep_generator
 from google.cloud.dataproc_v1 import Batch, Cluster, ClusterStatus, JobStatus
 
 from airflow.exceptions import AirflowProviderDeprecationWarning
-from airflow.providers.common.compat.sdk import AirflowException, conf, 
timezone
+from airflow.providers.common.compat.sdk import AirflowException, 
AirflowTaskTimeout, conf, timezone
 from airflow.providers.google.cloud.hooks.dataproc import (
     DataprocHook,
     DataProcJobBuilder,
@@ -1919,7 +1919,9 @@ class DataprocSubmitJobOperator(GoogleCloudBaseOperator):
     :param asynchronous: Flag to return after submitting the job to the 
Dataproc API.
         This is useful for submitting long-running jobs and
         waiting on them asynchronously using the DataprocJobSensor
-    :param deferrable: Run operator in the deferrable mode
+    :param deferrable: Run operator in the deferrable mode. 
``execution_timeout`` is enforced
+        while the task is deferred: on expiry the Dataproc job is cancelled 
(unless
+        ``cancel_on_kill`` is False) and the task fails.
     :param polling_interval_seconds: time in seconds between polling for job 
completion.
         The value is considered only when running in deferrable mode. Must be 
greater than 0.
     :param cancel_on_kill: Flag which indicates whether cancel the hook's job 
or not, when on_kill is called
@@ -2054,6 +2056,18 @@ class DataprocSubmitJobOperator(GoogleCloudBaseOperator):
                 raise AirflowException(f"Job failed:\n{job}")
             if state == JobStatus.State.CANCELLED:
                 raise AirflowException(f"Job was cancelled:\n{job}")
+            execution_deadline = None
+            trigger_timeout = None
+            if self.execution_timeout:
+                # Anchor on ti.start_date so the deadline stays stable across
+                # re-deferrals, matching non-deferrable execution_timeout 
semantics.
+                start_date = context["ti"].start_date
+                anchor = start_date.timestamp() if start_date else time.time()
+                execution_deadline = anchor + 
self.execution_timeout.total_seconds()
+                # Framework backstop only: slack of at least a minute (two 
poll intervals) so the
+                # trigger's own deadline handling (which cancels the job) 
fires first.
+                poll_buffer = max(60, 2 * self.polling_interval_seconds)
+                trigger_timeout = timedelta(seconds=max(execution_deadline - 
time.time(), 0) + poll_buffer)
             self.defer(
                 trigger=DataprocSubmitTrigger(
                     job_id=self.job_id,
@@ -2063,8 +2077,10 @@ class DataprocSubmitJobOperator(GoogleCloudBaseOperator):
                     impersonation_chain=self.impersonation_chain,
                     polling_interval_seconds=self.polling_interval_seconds,
                     cancel_on_kill=self.cancel_on_kill,
+                    execution_deadline=execution_deadline,
                 ),
                 method_name="execute_complete",
+                timeout=trigger_timeout,
             )
         elif not self.asynchronous:
             self.log.info("Waiting for job %s to complete", new_job_id)
@@ -2083,6 +2099,10 @@ class DataprocSubmitJobOperator(GoogleCloudBaseOperator):
         """
         job_state = event["job_state"]
         job_id = event["job_id"]
+        if job_state == "TIMED_OUT":
+            raise AirflowTaskTimeout(
+                event.get("message") or f"Job {job_id} exceeded the task's 
execution_timeout while deferred."
+            )
         job = event["job"]
         if job_state == JobStatus.State.ERROR.name:  # type: ignore
             raise AirflowException(f"Job {job_id} failed:\n{job}")
diff --git 
a/providers/google/src/airflow/providers/google/cloud/triggers/dataproc.py 
b/providers/google/src/airflow/providers/google/cloud/triggers/dataproc.py
index 665e336c0b0..cd877516028 100644
--- a/providers/google/src/airflow/providers/google/cloud/triggers/dataproc.py
+++ b/providers/google/src/airflow/providers/google/cloud/triggers/dataproc.py
@@ -104,10 +104,14 @@ class DataprocSubmitTrigger(DataprocBaseTrigger):
         Service Account Token Creator IAM role to the directly preceding 
identity, with first
         account from the list granting this role to the originating account 
(templated).
     :param polling_interval_seconds: polling period in seconds to check for 
the status
+    :param execution_deadline: Optional absolute timestamp (in seconds since 
the epoch) after
+        which the task is considered timed out. On expiry the trigger cancels 
the Dataproc job
+        (unless ``cancel_on_kill`` is False) and emits a ``TIMED_OUT`` event.
     """
 
-    def __init__(self, job_id: str, **kwargs):
+    def __init__(self, job_id: str, execution_deadline: float | None = None, 
**kwargs):
         self.job_id = job_id
+        self.execution_deadline = execution_deadline
         super().__init__(**kwargs)
 
     def serialize(self):
@@ -121,6 +125,7 @@ class DataprocSubmitTrigger(DataprocBaseTrigger):
                 "impersonation_chain": self.impersonation_chain,
                 "polling_interval_seconds": self.polling_interval_seconds,
                 "cancel_on_kill": self.cancel_on_kill,
+                "execution_deadline": self.execution_deadline,
             },
         )
 
@@ -205,12 +210,40 @@ class DataprocSubmitTrigger(DataprocBaseTrigger):
 
         try:
             while True:
-                job = await hook.get_job(project_id=self.project_id, 
region=self.region, job_id=self.job_id)
+                remaining = None if self.execution_deadline is None else 
self.execution_deadline - time.time()
+                if remaining is not None and remaining <= 0:
+                    if self.cancel_on_kill:
+                        self.log.info("Job %s exceeded execution_timeout; 
cancelling it.", self.job_id)
+                        try:
+                            await 
sync_to_async(self.get_sync_hook().cancel_job)(
+                                job_id=self.job_id, 
project_id=self.project_id, region=self.region
+                            )
+                        except Exception:
+                            self.log.exception("Failed to cancel job %s after 
execution timeout", self.job_id)
+                    yield TriggerEvent(
+                        {
+                            "job_id": self.job_id,
+                            "job_state": "TIMED_OUT",
+                            "message": f"Job {self.job_id} exceeded the task's 
execution_timeout "
+                            "while deferred.",
+                        }
+                    )
+                    return
+                try:
+                    job = await asyncio.wait_for(
+                        hook.get_job(project_id=self.project_id, 
region=self.region, job_id=self.job_id),
+                        timeout=remaining,
+                    )
+                except TimeoutError:
+                    continue
                 state = job.status.state
                 self.log.info("Dataproc job: %s is in state: %s", self.job_id, 
state)
                 if state in (JobStatus.State.DONE, JobStatus.State.CANCELLED, 
JobStatus.State.ERROR):
                     break
-                await asyncio.sleep(self.polling_interval_seconds)
+                sleep_seconds = self.polling_interval_seconds
+                if self.execution_deadline is not None:
+                    sleep_seconds = min(sleep_seconds, 
max(self.execution_deadline - time.time(), 0))
+                await asyncio.sleep(sleep_seconds)
             yield TriggerEvent(
                 {"job_id": self.job_id, "job_state": 
JobStatus.State(state).name, "job": Job.to_dict(job)}
             )
diff --git 
a/providers/google/tests/unit/google/cloud/operators/test_dataproc.py 
b/providers/google/tests/unit/google/cloud/operators/test_dataproc.py
index 29facc7c967..3b465fd29fb 100644
--- a/providers/google/tests/unit/google/cloud/operators/test_dataproc.py
+++ b/providers/google/tests/unit/google/cloud/operators/test_dataproc.py
@@ -23,6 +23,7 @@ from unittest import mock
 from unittest.mock import ANY, MagicMock, Mock, call
 
 import pytest
+import time_machine
 from google.api_core.exceptions import AlreadyExists, NotFound
 from google.api_core.retry import Retry
 from google.api_core.retry_async import AsyncRetry
@@ -1872,6 +1873,50 @@ class TestDataprocSubmitJobOperator(DataprocJobTestBase):
         assert isinstance(exc.value.trigger, DataprocSubmitTrigger)
         assert exc.value.method_name == GOOGLE_DEFAULT_DEFERRABLE_METHOD_NAME
 
+    @time_machine.travel(dt.datetime(2026, 1, 1, 0, 10, tzinfo=dt.UTC), 
tick=False)
+    @mock.patch(DATAPROC_PATH.format("DataprocHook"))
+    @mock.patch(DATAPROC_TRIGGERS_PATH.format("DataprocAsyncHook"))
+    def test_execute_deferrable_with_execution_timeout(self, 
mock_trigger_hook, mock_hook):
+        mock_hook.return_value.submit_job.return_value.reference.job_id = 
TEST_JOB_ID
+
+        op = DataprocSubmitJobOperator(
+            task_id=TASK_ID,
+            region=GCP_REGION,
+            project_id=GCP_PROJECT,
+            job={},
+            gcp_conn_id=GCP_CONN_ID,
+            deferrable=True,
+            execution_timeout=dt.timedelta(hours=1),
+        )
+        start_date = dt.datetime(2026, 1, 1, tzinfo=dt.UTC)
+        self.mock_ti.start_date = start_date
+
+        with pytest.raises(TaskDeferred) as exc:
+            op.execute(self.mock_context)
+
+        expected_deadline = start_date.timestamp() + 3600
+        assert exc.value.trigger.execution_deadline == expected_deadline
+        # 50 minutes remain, plus the 60 second minimum buffer.
+        assert exc.value.timeout == dt.timedelta(minutes=50, seconds=60)
+
+    @mock.patch(DATAPROC_PATH.format("DataprocHook"))
+    def test_execute_complete_timed_out_raises(self, mock_hook):
+        op = DataprocSubmitJobOperator(
+            task_id=TASK_ID,
+            region=GCP_REGION,
+            project_id=GCP_PROJECT,
+            job={},
+            gcp_conn_id=GCP_CONN_ID,
+            deferrable=True,
+        )
+        event = {
+            "job_id": TEST_JOB_ID,
+            "job_state": "TIMED_OUT",
+            "message": f"Job {TEST_JOB_ID} exceeded the task's 
execution_timeout while deferred.",
+        }
+        with pytest.raises(AirflowTaskTimeout, match="execution_timeout"):
+            op.execute_complete(context=self.mock_context, event=event)
+
     @mock.patch(DATAPROC_PATH.format("DataprocHook"))
     @mock.patch(DATAPROC_PATH.format("DataprocSubmitJobOperator.defer"))
     @mock.patch(DATAPROC_PATH.format("DataprocHook.submit_job"))
diff --git a/providers/google/tests/unit/google/cloud/triggers/test_dataproc.py 
b/providers/google/tests/unit/google/cloud/triggers/test_dataproc.py
index d469cdf478c..946e455d2f6 100644
--- a/providers/google/tests/unit/google/cloud/triggers/test_dataproc.py
+++ b/providers/google/tests/unit/google/cloud/triggers/test_dataproc.py
@@ -18,6 +18,7 @@ from __future__ import annotations
 
 import asyncio
 import logging
+import time
 from asyncio import CancelledError, Future, sleep
 from types import SimpleNamespace
 from unittest import mock
@@ -726,6 +727,7 @@ class TestDataprocSubmitTrigger:
             "polling_interval_seconds": TEST_POLL_INTERVAL,
             "cancel_on_kill": True,
             "impersonation_chain": None,
+            "execution_deadline": None,
         }
 
     @pytest.mark.asyncio
@@ -761,6 +763,99 @@ class TestDataprocSubmitTrigger:
         )
         assert event.payload == expected_event.payload
 
+    @pytest.mark.asyncio
+    
@mock.patch("airflow.providers.google.cloud.triggers.dataproc.DataprocSubmitTrigger.get_sync_hook")
+    
@mock.patch("airflow.providers.google.cloud.triggers.dataproc.DataprocSubmitTrigger.get_async_hook")
+    async def test_submit_trigger_run_execution_deadline_cancels_job(
+        self, mock_get_async_hook, mock_get_sync_hook
+    ):
+        """Test the trigger cancels a still-running job once the execution 
deadline passes."""
+        trigger = DataprocSubmitTrigger(
+            job_id=TEST_JOB_ID,
+            project_id=TEST_PROJECT_ID,
+            region=TEST_REGION,
+            gcp_conn_id=TEST_GCP_CONN_ID,
+            polling_interval_seconds=TEST_POLL_INTERVAL,
+            execution_deadline=time.time() - 1,
+        )
+        mock_job = Job(status=JobStatus(state=JobStatus.State.RUNNING))
+        mock_get_async_hook.return_value.get_job_client = mock.AsyncMock()
+        mock_get_async_hook.return_value.get_job = 
mock.AsyncMock(return_value=mock_job)
+        mock_get_sync_hook.return_value.cancel_job = mock.MagicMock()
+
+        event = await trigger.run().asend(None)
+
+        mock_get_async_hook.return_value.get_job.assert_not_awaited()
+        mock_get_sync_hook.return_value.cancel_job.assert_called_once_with(
+            job_id=TEST_JOB_ID,
+            project_id=TEST_PROJECT_ID,
+            region=TEST_REGION,
+        )
+        assert event.payload["job_id"] == TEST_JOB_ID
+        assert event.payload["job_state"] == "TIMED_OUT"
+
+    @pytest.mark.asyncio
+    
@mock.patch("airflow.providers.google.cloud.triggers.dataproc.DataprocSubmitTrigger.get_sync_hook")
+    
@mock.patch("airflow.providers.google.cloud.triggers.dataproc.DataprocSubmitTrigger.get_async_hook")
+    async def 
test_submit_trigger_run_execution_deadline_respects_cancel_on_kill_false(
+        self, mock_get_async_hook, mock_get_sync_hook
+    ):
+        """Test the deadline event is emitted without cancelling when 
cancel_on_kill is False."""
+        trigger = DataprocSubmitTrigger(
+            job_id=TEST_JOB_ID,
+            project_id=TEST_PROJECT_ID,
+            region=TEST_REGION,
+            gcp_conn_id=TEST_GCP_CONN_ID,
+            polling_interval_seconds=TEST_POLL_INTERVAL,
+            cancel_on_kill=False,
+            execution_deadline=time.time() - 1,
+        )
+        mock_job = Job(status=JobStatus(state=JobStatus.State.RUNNING))
+        mock_get_async_hook.return_value.get_job_client = mock.AsyncMock()
+        mock_get_async_hook.return_value.get_job = 
mock.AsyncMock(return_value=mock_job)
+
+        event = await trigger.run().asend(None)
+
+        mock_get_async_hook.return_value.get_job.assert_not_awaited()
+        mock_get_sync_hook.return_value.cancel_job.assert_not_called()
+        assert event.payload["job_state"] == "TIMED_OUT"
+
+    @pytest.mark.asyncio
+    @pytest.mark.parametrize("hanging_call", ["get_job", "sleep"])
+    
@mock.patch("airflow.providers.google.cloud.triggers.dataproc.DataprocSubmitTrigger.get_sync_hook")
+    
@mock.patch("airflow.providers.google.cloud.triggers.dataproc.DataprocSubmitTrigger.get_async_hook")
+    async def test_submit_trigger_run_execution_deadline_bounds_poll_and_sleep(
+        self, mock_get_async_hook, mock_get_sync_hook, hanging_call
+    ):
+        """Test a slow get_job or a long polling sleep cannot run past the 
execution deadline."""
+        trigger = DataprocSubmitTrigger(
+            job_id=TEST_JOB_ID,
+            project_id=TEST_PROJECT_ID,
+            region=TEST_REGION,
+            gcp_conn_id=TEST_GCP_CONN_ID,
+            polling_interval_seconds=60,
+            execution_deadline=time.time() + 0.2,
+        )
+        running_job = Job(status=JobStatus(state=JobStatus.State.RUNNING))
+
+        async def slow_get_job(**kwargs):
+            await asyncio.sleep(60)
+
+        mock_get_async_hook.return_value.get_job_client = mock.AsyncMock()
+        mock_get_async_hook.return_value.get_job = mock.AsyncMock(
+            side_effect=slow_get_job if hanging_call == "get_job" else None, 
return_value=running_job
+        )
+        mock_get_sync_hook.return_value.cancel_job = mock.MagicMock()
+
+        event = await asyncio.wait_for(trigger.run().asend(None), timeout=5)
+
+        mock_get_sync_hook.return_value.cancel_job.assert_called_once_with(
+            job_id=TEST_JOB_ID,
+            project_id=TEST_PROJECT_ID,
+            region=TEST_REGION,
+        )
+        assert event.payload["job_state"] == "TIMED_OUT"
+
     @pytest.mark.asyncio
     
@mock.patch("airflow.providers.google.cloud.triggers.dataproc.DataprocSubmitTrigger.get_sync_hook")
     async def test_on_kill_cancels_job(self, mock_get_sync_hook, 
submit_trigger):

Reply via email to