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 a5c28069af8 Cancel the Dataflow job when a user kills the deferred 
task (#69586)
a5c28069af8 is described below

commit a5c28069af8ca623294dd02890d8ae59f7f951b9
Author: Steve Ahn <[email protected]>
AuthorDate: Sat Oct 17 11:57:06 2026 -0700

    Cancel the Dataflow job when a user kills the deferred task (#69586)
---
 .../providers/google/cloud/operators/dataflow.py   |  37 +++-
 .../providers/google/cloud/triggers/dataflow.py    | 101 +++++++++-
 .../unit/google/cloud/operators/test_dataflow.py   | 116 +++++++++++
 .../unit/google/cloud/triggers/test_dataflow.py    | 224 ++++++++++++++++++++-
 4 files changed, 472 insertions(+), 6 deletions(-)

diff --git 
a/providers/google/src/airflow/providers/google/cloud/operators/dataflow.py 
b/providers/google/src/airflow/providers/google/cloud/operators/dataflow.py
index c5642b07072..9be06bde42e 100644
--- a/providers/google/src/airflow/providers/google/cloud/operators/dataflow.py
+++ b/providers/google/src/airflow/providers/google/cloud/operators/dataflow.py
@@ -298,6 +298,9 @@ class 
DataflowTemplatedJobStartOperator(GoogleCloudBaseOperator):
             
https://cloud.google.com/dataflow/docs/templates/executing-templates
 
     :param deferrable: Run operator in the deferrable mode.
+    :param cancel_on_kill: If True (default), cancel the Dataflow job when the 
task is killed,
+        both while the operator is running and, for a deferred task, while it 
waits in the
+        triggerer (Airflow 3.3+).
     """
 
     template_fields: Sequence[str] = (
@@ -334,11 +337,13 @@ class 
DataflowTemplatedJobStartOperator(GoogleCloudBaseOperator):
         append_job_name: bool = True,
         deferrable: bool = conf.getboolean("operators", "default_deferrable", 
fallback=False),
         expected_terminal_state: str | None = None,
+        cancel_on_kill: bool = True,
         **kwargs,
     ) -> None:
         super().__init__(**kwargs)
 
         self.template = template
+        self.cancel_on_kill = cancel_on_kill
         self.job_name = job_name
         self.options = options or {}
         self.dataflow_default_options = dataflow_default_options or {}
@@ -437,6 +442,7 @@ class 
DataflowTemplatedJobStartOperator(GoogleCloudBaseOperator):
                 poll_sleep=self.poll_sleep,
                 impersonation_chain=self.impersonation_chain,
                 cancel_timeout=self.cancel_timeout,
+                cancel_on_kill=self.cancel_on_kill,
             ),
             method_name=GOOGLE_DEFAULT_DEFERRABLE_METHOD_NAME,
         )
@@ -453,7 +459,10 @@ class 
DataflowTemplatedJobStartOperator(GoogleCloudBaseOperator):
         return job_id
 
     def on_kill(self) -> None:
+        """Cancel the running job; a kill of a deferred task cancels through 
the trigger instead."""
         self.log.info("On kill.")
+        if not self.cancel_on_kill:
+            return
         if self.job is not None:
             self.log.info("Cancelling job %s", self.job_name)
             self.hook.cancel_job(
@@ -525,6 +534,9 @@ class 
DataflowStartFlexTemplateOperator(GoogleCloudBaseOperator):
         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 deferrable: Run operator in the deferrable mode.
+    :param cancel_on_kill: If True (default), cancel the Dataflow job when the 
task is killed,
+        both while the operator is running and, for a deferred task, while it 
waits in the
+        triggerer (Airflow 3.3+).
     :param expected_terminal_state: The expected final status of the operator 
on which the corresponding
         Airflow task succeeds. When not specified, it will be determined by 
the hook.
     :param append_job_name: True if unique suffix has to be appended to job 
name.
@@ -550,11 +562,13 @@ class 
DataflowStartFlexTemplateOperator(GoogleCloudBaseOperator):
         append_job_name: bool = True,
         expected_terminal_state: str | None = None,
         poll_sleep: int = 10,
+        cancel_on_kill: bool = True,
         *args,
         **kwargs,
     ) -> None:
         super().__init__(*args, **kwargs)
         self.body = body
+        self.cancel_on_kill = cancel_on_kill
         self.location = location
         self.project_id = project_id
         self.gcp_conn_id = gcp_conn_id
@@ -633,6 +647,8 @@ class 
DataflowStartFlexTemplateOperator(GoogleCloudBaseOperator):
                 poll_sleep=self.poll_sleep,
                 impersonation_chain=self.impersonation_chain,
                 cancel_timeout=self.cancel_timeout,
+                drain_pipeline=self.drain_pipeline,
+                cancel_on_kill=self.cancel_on_kill,
             ),
             method_name=GOOGLE_DEFAULT_DEFERRABLE_METHOD_NAME,
         )
@@ -658,7 +674,10 @@ class 
DataflowStartFlexTemplateOperator(GoogleCloudBaseOperator):
         return job
 
     def on_kill(self) -> None:
+        """Cancel the running job; a kill of a deferred task cancels through 
the trigger instead."""
         self.log.info("On kill.")
+        if not self.cancel_on_kill:
+            return
         if self.job is not None:
             self.hook.cancel_job(
                 job_id=self.job.get("id"),
@@ -689,10 +708,13 @@ class 
DataflowStartYamlJobOperator(GoogleCloudBaseOperator):
     :param append_job_name: Optional. Set to True if a unique suffix has to be 
appended to the `job_name`.
         Defaults to True.
     :param drain_pipeline: Optional. Set to True if you want to stop a 
streaming pipeline job by draining it
-        instead of canceling when killing the task instance. Note that this 
does not work for batch pipeline jobs
-        or in the deferrable mode. Defaults to False.
+        instead of canceling when killing the task instance. Note that this 
does not work for batch pipeline jobs.
+        Defaults to False.
         For more info see: 
https://cloud.google.com/dataflow/docs/guides/stopping-a-pipeline
     :param deferrable: Optional. Run operator in the deferrable mode.
+    :param cancel_on_kill: If True (default), cancel the Dataflow job when the 
task is killed,
+        both while the operator is running and, for a deferred task, while it 
waits in the
+        triggerer (Airflow 3.3+).
     :param expected_terminal_state: Optional. The expected terminal state of 
the Dataflow job at which the
         operator task is set to succeed. Defaults to 'JOB_STATE_DONE' for the 
batch jobs and 'JOB_STATE_RUNNING'
         for the streaming jobs.
@@ -748,10 +770,12 @@ class 
DataflowStartYamlJobOperator(GoogleCloudBaseOperator):
         jinja_variables: dict[str, str] | None = None,
         options: dict[str, Any] | None = None,
         impersonation_chain: str | Sequence[str] | None = None,
+        cancel_on_kill: bool = True,
         **kwargs,
     ) -> None:
         super().__init__(**kwargs)
         self.job_name = job_name
+        self.cancel_on_kill = cancel_on_kill
         self.yaml_pipeline_file = yaml_pipeline_file
         self.region = region
         self.project_id = project_id
@@ -793,6 +817,8 @@ class DataflowStartYamlJobOperator(GoogleCloudBaseOperator):
                     cancel_timeout=self.cancel_timeout,
                     expected_terminal_state=self.expected_terminal_state,
                     impersonation_chain=self.impersonation_chain,
+                    drain_pipeline=self.drain_pipeline,
+                    cancel_on_kill=self.cancel_on_kill,
                 ),
                 method_name=GOOGLE_DEFAULT_DEFERRABLE_METHOD_NAME,
             )
@@ -818,10 +844,13 @@ class 
DataflowStartYamlJobOperator(GoogleCloudBaseOperator):
         """
         Cancel the dataflow job if a task instance gets killed.
 
-        This method will not be called if a task instance is killed in a 
deferred
-        state.
+        This method is not called for a task instance killed in a deferred 
state;
+        on Airflow 3.3+, the trigger requests cancellation instead, honoring 
cancel_on_kill
+        and drain_pipeline.
         """
         self.log.info("On kill called.")
+        if not self.cancel_on_kill:
+            return
         if self.job_id:
             self.hook.cancel_job(
                 job_id=self.job_id,
diff --git 
a/providers/google/src/airflow/providers/google/cloud/triggers/dataflow.py 
b/providers/google/src/airflow/providers/google/cloud/triggers/dataflow.py
index 4b6889d7325..004fc7d2a84 100644
--- a/providers/google/src/airflow/providers/google/cloud/triggers/dataflow.py
+++ b/providers/google/src/airflow/providers/google/cloud/triggers/dataflow.py
@@ -22,6 +22,7 @@ from collections.abc import Sequence
 from functools import cached_property
 from typing import TYPE_CHECKING, Any
 
+from asgiref.sync import sync_to_async
 from google.api_core.exceptions import ServiceUnavailable
 from google.cloud.dataflow_v1beta3 import JobState
 from google.cloud.dataflow_v1beta3.types import (
@@ -33,7 +34,11 @@ from google.cloud.dataflow_v1beta3.types import (
     MetricUpdate,
 )
 
-from airflow.providers.google.cloud.hooks.dataflow import AsyncDataflowHook, 
DataflowJobStatus
+from airflow.providers.google.cloud.hooks.dataflow import (
+    AsyncDataflowHook,
+    DataflowHook,
+    DataflowJobStatus,
+)
 from airflow.triggers.base import BaseTrigger, TriggerEvent
 
 if TYPE_CHECKING:
@@ -62,6 +67,10 @@ class TemplateJobStartTrigger(BaseTrigger):
         account from the list granting this role to the originating account 
(templated).
     :param cancel_timeout: Optional. How long (in seconds) operator should 
wait for the pipeline to be
         successfully cancelled when task is being killed.
+    :param cancel_on_kill: If True (default), cancel the Dataflow job when the 
user acts on the
+        deferred task (mark-failed, clear or mark-succeeded).
+    :param drain_pipeline: Optional. Set to True if you want a streaming job 
to be stopped by
+        draining it instead of cancelling, matching the operator's behaviour 
before deferral.
     """
 
     def __init__(
@@ -73,6 +82,8 @@ class TemplateJobStartTrigger(BaseTrigger):
         poll_sleep: int = 10,
         impersonation_chain: str | Sequence[str] | None = None,
         cancel_timeout: int | None = 5 * 60,
+        cancel_on_kill: bool = True,
+        drain_pipeline: bool = False,
     ):
         super().__init__()
         self.project_id = project_id
@@ -82,6 +93,8 @@ class TemplateJobStartTrigger(BaseTrigger):
         self.poll_sleep = poll_sleep
         self.impersonation_chain = impersonation_chain
         self.cancel_timeout = cancel_timeout
+        self.cancel_on_kill = cancel_on_kill
+        self.drain_pipeline = drain_pipeline
 
     def serialize(self) -> tuple[str, dict[str, Any]]:
         """Serialize class arguments and classpath."""
@@ -95,9 +108,48 @@ class TemplateJobStartTrigger(BaseTrigger):
                 "poll_sleep": self.poll_sleep,
                 "impersonation_chain": self.impersonation_chain,
                 "cancel_timeout": self.cancel_timeout,
+                "cancel_on_kill": self.cancel_on_kill,
+                "drain_pipeline": self.drain_pipeline,
             },
         )
 
+    async def on_kill(self) -> None:
+        """Stop the Dataflow job when the user acts on the deferred task."""
+        if not self.cancel_on_kill or not self.job_id:
+            return
+        self.log.info(
+            "Stopping Dataflow job. Project ID: %s, Location: %s, Job ID: %s, 
drain: %s",
+            self.project_id,
+            self.location,
+            self.job_id,
+            self.drain_pipeline,
+        )
+        try:
+            # Build the synchronous hook and cancel inside the worker thread: 
the hook resolves the
+            # connection eagerly during construction, which must not run in 
the triggerer's event loop.
+            await sync_to_async(self._stop_job)()
+            self.log.info("Cancellation or drain requested for Dataflow job 
%s.", self.job_id)
+        except Exception:
+            self.log.exception(
+                "Failed to stop Dataflow job %s. The job may still be 
running.",
+                self.job_id,
+            )
+
+    def _stop_job(self) -> None:
+        """Request cancellation or drain without waiting for the job to reach 
a terminal state."""
+        hook = DataflowHook(
+            gcp_conn_id=self.gcp_conn_id,
+            impersonation_chain=self.impersonation_chain,
+            drain_pipeline=self.drain_pipeline,
+            cancel_timeout=None,
+            poll_sleep=self.poll_sleep,
+        )
+        hook.cancel_job(
+            job_id=self.job_id,
+            project_id=self.project_id,
+            location=self.location,
+        )
+
     async def run(self):
         """
         Fetch job status or yield certain Events.
@@ -300,6 +352,10 @@ class DataflowStartYamlJobTrigger(BaseTrigger):
         If set as a sequence, the identities from the list must grant
         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 cancel_on_kill: If True (default), cancel the Dataflow job when the 
user acts on the
+        deferred task (mark failed, clear, or mark success).
+    :param drain_pipeline: Optional. Set to True if you want a streaming job 
to be stopped by
+        draining it instead of cancelling when the task is killed.
     """
 
     def __init__(
@@ -312,6 +368,8 @@ class DataflowStartYamlJobTrigger(BaseTrigger):
         cancel_timeout: int | None = 5 * 60,
         expected_terminal_state: str | None = None,
         impersonation_chain: str | Sequence[str] | None = None,
+        cancel_on_kill: bool = True,
+        drain_pipeline: bool = False,
     ):
         super().__init__()
         self.project_id = project_id
@@ -322,6 +380,8 @@ class DataflowStartYamlJobTrigger(BaseTrigger):
         self.cancel_timeout = cancel_timeout
         self.expected_terminal_state = expected_terminal_state
         self.impersonation_chain = impersonation_chain
+        self.cancel_on_kill = cancel_on_kill
+        self.drain_pipeline = drain_pipeline
 
     def serialize(self) -> tuple[str, dict[str, Any]]:
         """Serialize class arguments and classpath."""
@@ -336,9 +396,48 @@ class DataflowStartYamlJobTrigger(BaseTrigger):
                 "expected_terminal_state": self.expected_terminal_state,
                 "impersonation_chain": self.impersonation_chain,
                 "cancel_timeout": self.cancel_timeout,
+                "cancel_on_kill": self.cancel_on_kill,
+                "drain_pipeline": self.drain_pipeline,
             },
         )
 
+    async def on_kill(self) -> None:
+        """Stop the Dataflow job when the user acts on the deferred task."""
+        if not self.cancel_on_kill or not self.job_id:
+            return
+        self.log.info(
+            "Stopping Dataflow job. Project ID: %s, Location: %s, Job ID: %s, 
drain: %s",
+            self.project_id,
+            self.location,
+            self.job_id,
+            self.drain_pipeline,
+        )
+        try:
+            # Build the synchronous hook and cancel inside the worker thread: 
the hook resolves the
+            # connection eagerly during construction, which must not run in 
the triggerer's event loop.
+            await sync_to_async(self._stop_job)()
+            self.log.info("Cancellation or drain requested for Dataflow job 
%s.", self.job_id)
+        except Exception:
+            self.log.exception(
+                "Failed to stop Dataflow job %s. The job may still be 
running.",
+                self.job_id,
+            )
+
+    def _stop_job(self) -> None:
+        """Request cancellation or drain without waiting for the job to reach 
a terminal state."""
+        hook = DataflowHook(
+            gcp_conn_id=self.gcp_conn_id,
+            impersonation_chain=self.impersonation_chain,
+            drain_pipeline=self.drain_pipeline,
+            cancel_timeout=None,
+            poll_sleep=self.poll_sleep,
+        )
+        hook.cancel_job(
+            job_id=self.job_id,
+            project_id=self.project_id,
+            location=self.location,
+        )
+
     async def run(self):
         """
         Fetch job and yield events depending on the job's type and state.
diff --git 
a/providers/google/tests/unit/google/cloud/operators/test_dataflow.py 
b/providers/google/tests/unit/google/cloud/operators/test_dataflow.py
index 90b444dd6c4..9db80b84b1a 100644
--- a/providers/google/tests/unit/google/cloud/operators/test_dataflow.py
+++ b/providers/google/tests/unit/google/cloud/operators/test_dataflow.py
@@ -210,6 +210,32 @@ class TestDataflowTemplatedJobStartOperator:
         )
         mock_defer_method.assert_called_once()
 
+    @mock.patch(f"{DATAFLOW_PATH}.DataflowTemplatedJobStartOperator.defer", 
autospec=True)
+    @mock.patch(f"{DATAFLOW_PATH}.DataflowHook", autospec=True)
+    def test_deferrable_threads_cancel_on_kill_false_into_trigger(self, 
mock_hook, mock_defer_method):
+        """cancel_on_kill=False reaches the trigger, so a deferred kill leaves 
the job running."""
+        operator = DataflowTemplatedJobStartOperator(
+            project_id=TEST_PROJECT,
+            task_id=TASK_ID,
+            template=TEMPLATE,
+            job_name=JOB_NAME,
+            location=TEST_LOCATION,
+            deferrable=True,
+            cancel_on_kill=False,
+        )
+        operator.execute(mock.MagicMock(spec=dict))
+
+        assert mock_defer_method.call_args.kwargs["trigger"].cancel_on_kill is 
False
+
+    @mock.patch(f"{DATAFLOW_PATH}.DataflowHook", autospec=True)
+    def test_on_kill_respects_cancel_on_kill_false(self, mock_hook, 
sync_operator):
+        sync_operator.cancel_on_kill = False
+        sync_operator.job = {"id": "test-job", "projectId": TEST_PROJECT, 
"location": TEST_LOCATION}
+
+        sync_operator.on_kill()
+
+        mock_hook.return_value.cancel_job.assert_not_called()
+
     def test_validation_deferrable_params_raises_error(self):
         init_kwargs = {
             "project_id": TEST_PROJECT,
@@ -347,6 +373,46 @@ class TestDataflowStartFlexTemplateOperator:
         )
         mock_defer_method.assert_called_once()
 
+    @mock.patch(f"{DATAFLOW_PATH}.DataflowStartFlexTemplateOperator.defer", 
autospec=True)
+    @mock.patch(f"{DATAFLOW_PATH}.DataflowHook", autospec=True)
+    def test_deferrable_threads_cancel_on_kill_false_into_trigger(self, 
mock_hook, mock_defer_method):
+        """cancel_on_kill=False reaches the trigger, so a deferred kill leaves 
the job running."""
+        operator = DataflowStartFlexTemplateOperator(
+            task_id="start_flex_template_streaming_beam_sql",
+            body={"launchParameter": TEST_FLEX_PARAMETERS},
+            project_id=TEST_PROJECT,
+            location=TEST_LOCATION,
+            deferrable=True,
+            cancel_on_kill=False,
+        )
+        operator.execute(mock.MagicMock(spec=dict))
+
+        assert mock_defer_method.call_args.kwargs["trigger"].cancel_on_kill is 
False
+
+    @mock.patch(f"{DATAFLOW_PATH}.DataflowHook", autospec=True)
+    def test_on_kill_respects_cancel_on_kill_false(self, mock_hook, 
sync_operator):
+        sync_operator.cancel_on_kill = False
+        sync_operator.job = {"id": "test-job", "projectId": TEST_PROJECT, 
"location": TEST_LOCATION}
+
+        sync_operator.on_kill()
+
+        mock_hook.return_value.cancel_job.assert_not_called()
+
+    @mock.patch(f"{DATAFLOW_PATH}.DataflowStartFlexTemplateOperator.defer", 
autospec=True)
+    @mock.patch(f"{DATAFLOW_PATH}.DataflowHook", autospec=True)
+    def test_deferrable_threads_drain_pipeline_into_trigger(self, mock_hook, 
mock_defer_method):
+        operator = DataflowStartFlexTemplateOperator(
+            task_id="start_flex_template_streaming_beam_sql",
+            body={"launchParameter": TEST_FLEX_PARAMETERS},
+            project_id=TEST_PROJECT,
+            location=TEST_LOCATION,
+            deferrable=True,
+            drain_pipeline=True,
+        )
+        operator.execute(mock.MagicMock(spec=dict))
+
+        assert mock_defer_method.call_args.kwargs["trigger"].drain_pipeline is 
True
+
 
 class TestDataflowStartYamlJobOperator:
     @pytest.fixture
@@ -411,6 +477,56 @@ class TestDataflowStartYamlJobOperator:
         )
         mock_defer_method.assert_called_once()
 
+    @mock.patch(f"{DATAFLOW_PATH}.DataflowStartYamlJobOperator.defer", 
autospec=True)
+    @mock.patch(f"{DATAFLOW_PATH}.DataflowHook", autospec=True)
+    def test_deferrable_threads_cancel_on_kill_false_into_trigger(self, 
mock_hook, mock_defer_method):
+        """cancel_on_kill=False reaches the trigger, so a deferred kill leaves 
the job running."""
+        operator = DataflowStartYamlJobOperator(
+            task_id="start_dataflow_yaml_job_cancel_false",
+            job_name="dataflow_yaml_job",
+            yaml_pipeline_file="test_file_path",
+            append_job_name=False,
+            project_id=TEST_PROJECT,
+            region=TEST_LOCATION,
+            deferrable=True,
+            cancel_on_kill=False,
+            expected_terminal_state=DataflowJobStatus.JOB_STATE_RUNNING,
+        )
+        operator.execute(mock.MagicMock(spec=dict))
+
+        assert mock_defer_method.call_args.kwargs["trigger"].cancel_on_kill is 
False
+
+    @mock.patch(f"{DATAFLOW_PATH}.DataflowHook", autospec=True)
+    def test_on_kill_respects_cancel_on_kill_false(self, mock_hook, 
sync_operator):
+        sync_operator.cancel_on_kill = False
+        sync_operator.job_id = "test-job-id"
+
+        sync_operator.on_kill()
+
+        mock_hook.return_value.cancel_job.assert_not_called()
+
+    @mock.patch(f"{DATAFLOW_PATH}.DataflowStartYamlJobOperator.defer", 
autospec=True)
+    @mock.patch(f"{DATAFLOW_PATH}.DataflowHook", autospec=True)
+    def test_deferrable_threads_drain_pipeline_into_trigger(self, mock_hook, 
mock_defer_method):
+        """drain_pipeline reaches the trigger so a killed deferred task drains 
instead of cancels."""
+        operator = DataflowStartYamlJobOperator(
+            task_id="start_dataflow_yaml_job_drain",
+            job_name="dataflow_yaml_job",
+            yaml_pipeline_file="test_file_path",
+            append_job_name=False,
+            project_id=TEST_PROJECT,
+            region=TEST_LOCATION,
+            gcp_conn_id=GCP_CONN_ID,
+            deferrable=True,
+            drain_pipeline=True,
+            expected_terminal_state=DataflowJobStatus.JOB_STATE_RUNNING,
+        )
+        operator.execute(mock.MagicMock(spec=dict))
+
+        trigger = mock_defer_method.call_args.kwargs["trigger"]
+        assert trigger.drain_pipeline is True
+        assert trigger.cancel_on_kill is True
+
     @mock.patch(f"{DATAFLOW_PATH}.DataflowHook")
     def test_execute_complete_success(self, mock_hook, deferrable_operator):
         expected_result = {"id": JOB_ID}
diff --git a/providers/google/tests/unit/google/cloud/triggers/test_dataflow.py 
b/providers/google/tests/unit/google/cloud/triggers/test_dataflow.py
index b179f40fa3a..5d21878c08c 100644
--- a/providers/google/tests/unit/google/cloud/triggers/test_dataflow.py
+++ b/providers/google/tests/unit/google/cloud/triggers/test_dataflow.py
@@ -25,7 +25,7 @@ import pytest
 from google.api_core.exceptions import ServiceUnavailable
 from google.cloud.dataflow_v1beta3 import Job, JobState, JobType
 
-from airflow.providers.google.cloud.hooks.dataflow import DataflowJobStatus
+from airflow.providers.google.cloud.hooks.dataflow import DataflowJobStatus, 
DataflowJobType
 from airflow.providers.google.cloud.triggers.dataflow import (
     DataflowJobAutoScalingEventTrigger,
     DataflowJobMessagesTrigger,
@@ -143,6 +143,62 @@ def test_dataflow_batch_job():
     return Job(id=JOB_ID, current_state=JobState.JOB_STATE_DONE, 
type_=JobType.JOB_TYPE_BATCH)
 
 
[email protected]
[email protected]("trigger_class", [TemplateJobStartTrigger, 
DataflowStartYamlJobTrigger])
[email protected]("project_id", [PROJECT_ID, None], 
ids=["explicit-project", "connection-project"])
[email protected](
+    ("drain_pipeline", "requested_state"),
+    [(False, DataflowJobStatus.JOB_STATE_CANCELLED), (True, 
DataflowJobStatus.JOB_STATE_DRAINED)],
+)
[email protected](
+    
"airflow.providers.google.cloud.hooks.dataflow._DataflowJobsController._wait_for_states",
 autospec=True
+)
[email protected]("airflow.providers.google.cloud.hooks.dataflow.DataflowHook.get_conn",
 autospec=True)
[email protected]("airflow.providers.google.common.hooks.base_google.BaseHook.get_connection",
 autospec=True)
[email protected](
+    
"airflow.providers.google.common.hooks.base_google.GoogleBaseHook.get_credentials_and_project_id",
+    autospec=True,
+    return_value=(None, PROJECT_ID),
+)
+async def test_on_kill_requests_stop_without_waiting(
+    mock_credentials,
+    mock_connection,
+    mock_get_conn,
+    mock_wait_for_states,
+    trigger_class,
+    project_id,
+    drain_pipeline,
+    requested_state,
+):
+    mock_connection.return_value.extra_dejson = {}
+    jobs = 
mock_get_conn.return_value.projects.return_value.locations.return_value.jobs.return_value
+    jobs.get.return_value.execute.return_value = {
+        "id": JOB_ID,
+        "name": "test-job",
+        "currentState": DataflowJobStatus.JOB_STATE_RUNNING,
+        "type": DataflowJobType.JOB_TYPE_STREAMING,
+    }
+    trigger = trigger_class(
+        project_id=project_id,
+        job_id=JOB_ID,
+        location=LOCATION,
+        gcp_conn_id=GCP_CONN_ID,
+        cancel_timeout=CANCEL_TIMEOUT,
+        drain_pipeline=drain_pipeline,
+    )
+
+    await trigger.on_kill()
+
+    jobs.update.assert_called_once_with(
+        projectId=PROJECT_ID,
+        location=LOCATION,
+        jobId=JOB_ID,
+        body={"requestedState": requested_state},
+    )
+    jobs.update.return_value.execute.assert_called_once()
+    mock_wait_for_states.assert_not_called()
+
+
 class TestTemplateJobStartTrigger:
     def test_serialize(self, template_job_start_trigger):
         actual_data = template_job_start_trigger.serialize()
@@ -156,6 +212,8 @@ class TestTemplateJobStartTrigger:
                 "poll_sleep": POLL_SLEEP,
                 "impersonation_chain": IMPERSONATION_CHAIN,
                 "cancel_timeout": CANCEL_TIMEOUT,
+                "cancel_on_kill": True,
+                "drain_pipeline": False,
             },
         )
         assert actual_data == expected_data
@@ -175,6 +233,87 @@ class TestTemplateJobStartTrigger:
         assert actual is not None
         assert actual == expected
 
+    @pytest.mark.asyncio
+    @pytest.mark.parametrize("project_id", [PROJECT_ID, None], 
ids=["explicit-project", "connection-project"])
+    
@mock.patch("airflow.providers.google.cloud.triggers.dataflow.DataflowHook", 
autospec=True)
+    async def test_on_kill_stops_the_job(self, mock_hook_class, 
template_job_start_trigger, project_id):
+        template_job_start_trigger.project_id = project_id
+        await template_job_start_trigger.on_kill()
+
+        mock_hook_class.assert_called_once_with(
+            gcp_conn_id=GCP_CONN_ID,
+            impersonation_chain=IMPERSONATION_CHAIN,
+            drain_pipeline=False,
+            cancel_timeout=None,
+            poll_sleep=POLL_SLEEP,
+        )
+        mock_hook_class.return_value.cancel_job.assert_called_once_with(
+            job_id=JOB_ID,
+            project_id=project_id,
+            location=LOCATION,
+        )
+
+    @pytest.mark.asyncio
+    
@mock.patch("airflow.providers.google.cloud.triggers.dataflow.DataflowHook", 
autospec=True)
+    async def test_on_kill_passes_drain_pipeline_to_the_hook(self, 
mock_hook_class):
+        trigger = TemplateJobStartTrigger(
+            project_id=PROJECT_ID,
+            job_id=JOB_ID,
+            location=LOCATION,
+            gcp_conn_id=GCP_CONN_ID,
+            poll_sleep=POLL_SLEEP,
+            impersonation_chain=IMPERSONATION_CHAIN,
+            cancel_timeout=CANCEL_TIMEOUT,
+            drain_pipeline=True,
+        )
+
+        await trigger.on_kill()
+
+        assert mock_hook_class.call_args.kwargs["drain_pipeline"] is True
+
+    @pytest.mark.parametrize(
+        ("cancel_on_kill", "job_id", "project_id"),
+        [
+            pytest.param(False, JOB_ID, PROJECT_ID, 
id="cancel-on-kill-disabled"),
+            pytest.param(True, None, PROJECT_ID, id="no-job-id"),
+        ],
+    )
+    @pytest.mark.asyncio
+    
@mock.patch("airflow.providers.google.cloud.triggers.dataflow.DataflowHook", 
autospec=True)
+    async def test_on_kill_does_not_stop_the_job(self, mock_hook_class, 
cancel_on_kill, job_id, project_id):
+        trigger = TemplateJobStartTrigger(
+            project_id=project_id,
+            job_id=job_id,
+            location=LOCATION,
+            gcp_conn_id=GCP_CONN_ID,
+            cancel_on_kill=cancel_on_kill,
+        )
+
+        await trigger.on_kill()
+
+        mock_hook_class.assert_not_called()
+
+    @pytest.mark.asyncio
+    
@mock.patch("airflow.providers.google.cloud.triggers.dataflow.DataflowHook", 
autospec=True)
+    async def test_on_kill_swallows_hook_errors(self, mock_hook_class, 
template_job_start_trigger):
+        mock_hook_class.return_value.cancel_job.side_effect = 
RuntimeError("api down")
+
+        await template_job_start_trigger.on_kill()
+
+        mock_hook_class.return_value.cancel_job.assert_called_once()
+
+    @pytest.mark.asyncio
+    
@mock.patch("airflow.providers.google.cloud.triggers.dataflow.DataflowHook", 
autospec=True)
+    async def test_on_kill_swallows_hook_construction_errors(
+        self, mock_hook_class, template_job_start_trigger
+    ):
+        # The hook resolves its connection during construction; that must not 
escape on_kill.
+        mock_hook_class.side_effect = RuntimeError("no connection")
+
+        await template_job_start_trigger.on_kill()
+
+        mock_hook_class.assert_called_once()
+
     @pytest.mark.asyncio
     
@mock.patch("airflow.providers.google.cloud.hooks.dataflow.AsyncDataflowHook.get_job_status")
     async def test_run_loop_return_success_event(self, mock_job_status, 
template_job_start_trigger):
@@ -819,10 +958,93 @@ class TestDataflowStartYamlJobTrigger:
                 "expected_terminal_state": None,
                 "impersonation_chain": IMPERSONATION_CHAIN,
                 "cancel_timeout": CANCEL_TIMEOUT,
+                "cancel_on_kill": True,
+                "drain_pipeline": False,
             },
         )
         assert actual_data == expected_data
 
+    @pytest.mark.asyncio
+    @pytest.mark.parametrize("project_id", [PROJECT_ID, None], 
ids=["explicit-project", "connection-project"])
+    
@mock.patch("airflow.providers.google.cloud.triggers.dataflow.DataflowHook", 
autospec=True)
+    async def test_on_kill_stops_the_job(self, mock_hook_class, 
dataflow_start_yaml_job_trigger, project_id):
+        dataflow_start_yaml_job_trigger.project_id = project_id
+        await dataflow_start_yaml_job_trigger.on_kill()
+
+        mock_hook_class.assert_called_once_with(
+            gcp_conn_id=GCP_CONN_ID,
+            impersonation_chain=IMPERSONATION_CHAIN,
+            drain_pipeline=False,
+            cancel_timeout=None,
+            poll_sleep=POLL_SLEEP,
+        )
+        mock_hook_class.return_value.cancel_job.assert_called_once_with(
+            job_id=JOB_ID,
+            project_id=project_id,
+            location=LOCATION,
+        )
+
+    @pytest.mark.asyncio
+    
@mock.patch("airflow.providers.google.cloud.triggers.dataflow.DataflowHook", 
autospec=True)
+    async def test_on_kill_passes_drain_pipeline_to_the_hook(self, 
mock_hook_class):
+        trigger = DataflowStartYamlJobTrigger(
+            project_id=PROJECT_ID,
+            job_id=JOB_ID,
+            location=LOCATION,
+            gcp_conn_id=GCP_CONN_ID,
+            poll_sleep=POLL_SLEEP,
+            impersonation_chain=IMPERSONATION_CHAIN,
+            cancel_timeout=CANCEL_TIMEOUT,
+            drain_pipeline=True,
+        )
+
+        await trigger.on_kill()
+
+        assert mock_hook_class.call_args.kwargs["drain_pipeline"] is True
+
+    @pytest.mark.parametrize(
+        ("cancel_on_kill", "job_id", "project_id"),
+        [
+            pytest.param(False, JOB_ID, PROJECT_ID, 
id="cancel-on-kill-disabled"),
+            pytest.param(True, None, PROJECT_ID, id="no-job-id"),
+        ],
+    )
+    @pytest.mark.asyncio
+    
@mock.patch("airflow.providers.google.cloud.triggers.dataflow.DataflowHook", 
autospec=True)
+    async def test_on_kill_does_not_stop_the_job(self, mock_hook_class, 
cancel_on_kill, job_id, project_id):
+        trigger = DataflowStartYamlJobTrigger(
+            project_id=project_id,
+            job_id=job_id,
+            location=LOCATION,
+            gcp_conn_id=GCP_CONN_ID,
+            cancel_on_kill=cancel_on_kill,
+        )
+
+        await trigger.on_kill()
+
+        mock_hook_class.assert_not_called()
+
+    @pytest.mark.asyncio
+    
@mock.patch("airflow.providers.google.cloud.triggers.dataflow.DataflowHook", 
autospec=True)
+    async def test_on_kill_swallows_hook_errors(self, mock_hook_class, 
dataflow_start_yaml_job_trigger):
+        mock_hook_class.return_value.cancel_job.side_effect = 
RuntimeError("api down")
+
+        await dataflow_start_yaml_job_trigger.on_kill()
+
+        mock_hook_class.return_value.cancel_job.assert_called_once()
+
+    @pytest.mark.asyncio
+    
@mock.patch("airflow.providers.google.cloud.triggers.dataflow.DataflowHook", 
autospec=True)
+    async def test_on_kill_swallows_hook_construction_errors(
+        self, mock_hook_class, dataflow_start_yaml_job_trigger
+    ):
+        # The hook resolves its connection during construction; that must not 
escape on_kill.
+        mock_hook_class.side_effect = RuntimeError("no connection")
+
+        await dataflow_start_yaml_job_trigger.on_kill()
+
+        mock_hook_class.assert_called_once()
+
     @pytest.mark.parametrize(
         ("attr", "expected"),
         [

Reply via email to