This is an automated email from the ASF dual-hosted git repository.
potiuk 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 dd4d2f9ef7c Add CloudSQLNoOperationInProgressSensor for parallel admin
ops (#68151)
dd4d2f9ef7c is described below
commit dd4d2f9ef7c8e4cc12d9b10dcc3334af5dee9fb6
Author: deepinsight coder <[email protected]>
AuthorDate: Fri Jul 31 13:23:48 2026 -0700
Add CloudSQLNoOperationInProgressSensor for parallel admin ops (#68151)
---
.../google/docs/operators/cloud/cloud_sql.rst | 39 ++++++
providers/google/provider.yaml | 3 +
.../providers/google/cloud/hooks/cloud_sql.py | 32 +++++
.../providers/google/cloud/sensors/cloud_sql.py | 147 +++++++++++++++++++++
.../providers/google/cloud/triggers/cloud_sql.py | 81 +++++++++++-
.../airflow/providers/google/get_provider_info.py | 4 +
.../google/cloud/cloud_sql/example_cloud_sql.py | 11 ++
.../unit/google/cloud/hooks/test_cloud_sql.py | 20 +++
.../unit/google/cloud/sensors/test_cloud_sql.py | 127 ++++++++++++++++++
.../unit/google/cloud/triggers/test_cloud_sql.py | 82 +++++++++++-
10 files changed, 544 insertions(+), 2 deletions(-)
diff --git a/providers/google/docs/operators/cloud/cloud_sql.rst
b/providers/google/docs/operators/cloud/cloud_sql.rst
index dd1ea693048..544ab207157 100644
--- a/providers/google/docs/operators/cloud/cloud_sql.rst
+++ b/providers/google/docs/operators/cloud/cloud_sql.rst
@@ -359,6 +359,45 @@ as shown in the example:
:start-after: [START howto_operator_cloudsql_import_gcs_permissions]
:end-before: [END howto_operator_cloudsql_import_gcs_permissions]
+.. _howto/operator:CloudSQLNoOperationInProgressSensor:
+
+CloudSQLNoOperationInProgressSensor
+-----------------------------------
+
+Cloud SQL serializes administrative operations per instance: only one import,
export, backup or
+similar operation can run against an instance at a time. Submitting another
while one is in flight
+fails immediately with HTTP 409 ``operationInProgress``. This commonly affects
DAGs that fan out
+multiple
:class:`~airflow.providers.google.cloud.operators.cloud_sql.CloudSQLImportInstanceOperator`
+/
:class:`~airflow.providers.google.cloud.operators.cloud_sql.CloudSQLExportInstanceOperator`
tasks
+against the same instance in parallel.
+
+Use
+:class:`~airflow.providers.google.cloud.sensors.cloud_sql.CloudSQLNoOperationInProgressSensor`
+to wait until the instance has no administrative operation in progress before
submitting the next
+one. The sensor polls ``sqladmin.operations.list`` for the instance and
succeeds once no operation
+is in a non-terminal (``PENDING`` / ``RUNNING``) state. It supports deferrable
mode and fails fast
+on HTTP 403/404 (the instance is missing or access is denied).
+
+.. code-block:: python
+
+ from airflow.providers.google.cloud.sensors.cloud_sql import (
+ CloudSQLNoOperationInProgressSensor,
+ )
+
+ wait_for_slot = CloudSQLNoOperationInProgressSensor(
+ task_id="wait_for_slot",
+ instance="my-cloudsql-pg",
+ poke_interval=60,
+ timeout=2 * 60 * 60,
+ deferrable=True,
+ )
+
+ wait_for_slot >> import_data
+
+The sensor is best-effort: it reduces the chance of a 409 but cannot guarantee
exclusivity, since a
+new operation (for example one triggered from the console or by an automated
backup) could start
+between the sensor passing and the operator submitting.
+
.. _howto/operator:CloudSQLCreateInstanceOperator:
CloudSQLCreateInstanceOperator
diff --git a/providers/google/provider.yaml b/providers/google/provider.yaml
index 4a1fe1d3589..e1e6d841c6e 100644
--- a/providers/google/provider.yaml
+++ b/providers/google/provider.yaml
@@ -653,6 +653,9 @@ sensors:
- integration-name: Google Bigtable
python-modules:
- airflow.providers.google.cloud.sensors.bigtable
+ - integration-name: Google Cloud SQL
+ python-modules:
+ - airflow.providers.google.cloud.sensors.cloud_sql
- integration-name: Managed Service for Apache Airflow
python-modules:
- airflow.providers.google.cloud.sensors.cloud_composer
diff --git
a/providers/google/src/airflow/providers/google/cloud/hooks/cloud_sql.py
b/providers/google/src/airflow/providers/google/cloud/hooks/cloud_sql.py
index d4d5f18134f..57889e96c2b 100644
--- a/providers/google/src/airflow/providers/google/cloud/hooks/cloud_sql.py
+++ b/providers/google/src/airflow/providers/google/cloud/hooks/cloud_sql.py
@@ -92,6 +92,15 @@ class CloudSqlOperationStatus:
UNKNOWN = "UNKNOWN"
+# Statuses that mean an administrative operation is still in flight on the
instance. Cloud SQL
+# serializes admin operations per instance, so a new import/export submitted
while one of these is
+# active fails with HTTP 409 ``operationInProgress``. Keying off an explicit
set (rather than
+# ``status != DONE``) avoids treating UNKNOWN/unexpected statuses as
in-progress and poking forever.
+CLOUD_SQL_NON_TERMINAL_STATUSES = frozenset(
+ {CloudSqlOperationStatus.PENDING, CloudSqlOperationStatus.RUNNING}
+)
+
+
class CloudSQLHook(GoogleBaseHook):
"""
Hook for Google Cloud SQL APIs.
@@ -429,6 +438,29 @@ class CloudSQLHook(GoogleBaseHook):
.execute(num_retries=self.num_retries)
)
+ @GoogleBaseHook.fallback_to_default_project_id
+ def list_operations(self, instance: str, project_id: str, max_results: int
| None = None) -> list[dict]:
+ """
+ List administrative operations for a Cloud SQL instance.
+
+ Must be called with keyword arguments because ``project_id`` is
injected by the
+ ``fallback_to_default_project_id`` decorator.
+
+ :param instance: Name of the Cloud SQL instance whose operations are
listed.
+ :param project_id: Project ID of the project that contains the
instance.
+ :param max_results: Optional maximum number of operations to return
per page.
+ :return: The list of operation resources for the instance (may be
empty).
+ """
+ response = (
+ self.get_conn()
+ .operations()
+ .list(project=project_id, instance=instance,
maxResults=max_results)
+ .execute(num_retries=self.num_retries)
+ )
+ # ``operations.list`` already filters server-side by ``instance``;
keep a defensive
+ # client-side filter on ``targetId`` in case the API ever returns
broader results.
+ return [op for op in response.get("items", []) if op.get("targetId")
== instance]
+
@GoogleBaseHook.fallback_to_default_project_id
def _wait_for_operation_to_complete(
self, project_id: str, operation_name: str, time_to_sleep: int =
TIME_TO_SLEEP_IN_SECONDS
diff --git
a/providers/google/src/airflow/providers/google/cloud/sensors/cloud_sql.py
b/providers/google/src/airflow/providers/google/cloud/sensors/cloud_sql.py
new file mode 100644
index 00000000000..50f0576e10a
--- /dev/null
+++ b/providers/google/src/airflow/providers/google/cloud/sensors/cloud_sql.py
@@ -0,0 +1,147 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements. See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership. The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License. You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied. See the License for the
+# specific language governing permissions and limitations
+# under the License.
+"""This module contains Google Cloud SQL sensors."""
+
+from __future__ import annotations
+
+from collections.abc import Sequence
+from datetime import timedelta
+from typing import TYPE_CHECKING
+
+from googleapiclient.errors import HttpError
+
+from airflow.providers.common.compat.sdk import AirflowException,
BaseSensorOperator, conf
+from airflow.providers.google.cloud.hooks.cloud_sql import (
+ CLOUD_SQL_NON_TERMINAL_STATUSES,
+ CloudSQLHook,
+)
+from airflow.providers.google.cloud.triggers.cloud_sql import
CloudSQLNoOperationInProgressTrigger
+from airflow.providers.google.common.hooks.base_google import
PROVIDE_PROJECT_ID
+
+if TYPE_CHECKING:
+ from airflow.providers.common.compat.sdk import Context
+
+
+class CloudSQLOperationError(AirflowException):
+ """Raised when the Cloud SQL operations check fails (for example, the
instance is missing or access is denied)."""
+
+
+class CloudSQLNoOperationInProgressSensor(BaseSensorOperator):
+ """
+ Wait until a Cloud SQL instance has no administrative operation in
progress.
+
+ Cloud SQL serializes administrative operations per instance: only one
import, export, backup or
+ similar operation can run at a time. Submitting another while one is in
flight fails with HTTP
+ 409 ``operationInProgress``. Place this sensor upstream of
+
:class:`~airflow.providers.google.cloud.operators.cloud_sql.CloudSQLImportInstanceOperator`
/
+
:class:`~airflow.providers.google.cloud.operators.cloud_sql.CloudSQLExportInstanceOperator`
+ (or between mutually exclusive admin operators) to serialize work against
the same instance.
+
+ The sensor is operation-agnostic: it polls ``sqladmin.operations.list``
for the instance and
+ succeeds once no operation is in a non-terminal (PENDING/RUNNING) state.
It is best-effort -- a
+ new operation could still start between the sensor passing and the next
operator submitting.
+
+ .. seealso::
+ For more information on how to use this sensor, take a look at the
guide:
+ :ref:`howto/operator:CloudSQLNoOperationInProgressSensor`
+
+ :param instance: Name of the Cloud SQL instance to watch.
+ :param project_id: Optional, Google Cloud Project ID. If not provided the
default project is used.
+ :param gcp_conn_id: The connection ID used to connect to Google Cloud.
+ :param api_version: API version used (e.g. v1beta4).
+ :param impersonation_chain: Optional service account to impersonate using
short-term
+ credentials, or chained list of accounts required to get the
access_token of the last
+ account in the list, which will be impersonated in the request.
+ :param deferrable: Run the sensor in deferrable mode.
+ """
+
+ template_fields: Sequence[str] = (
+ "project_id",
+ "instance",
+ "impersonation_chain",
+ )
+ ui_color = "#D4ECEA"
+
+ def __init__(
+ self,
+ *,
+ instance: str,
+ project_id: str = PROVIDE_PROJECT_ID,
+ gcp_conn_id: str = "google_cloud_default",
+ api_version: str = "v1beta4",
+ impersonation_chain: str | Sequence[str] | None = None,
+ deferrable: bool = conf.getboolean("operators", "default_deferrable",
fallback=False),
+ **kwargs,
+ ) -> None:
+ super().__init__(**kwargs)
+ self.instance = instance
+ self.project_id = project_id
+ self.gcp_conn_id = gcp_conn_id
+ self.api_version = api_version
+ self.impersonation_chain = impersonation_chain
+ self.deferrable = deferrable
+
+ def _get_hook(self) -> CloudSQLHook:
+ return CloudSQLHook(
+ api_version=self.api_version,
+ gcp_conn_id=self.gcp_conn_id,
+ impersonation_chain=self.impersonation_chain,
+ )
+
+ def poke(self, context: Context) -> bool:
+ hook = self._get_hook()
+ try:
+ operations = hook.list_operations(project_id=self.project_id,
instance=self.instance)
+ except HttpError as e:
+ if e.resp.status in (403, 404):
+ # Instance missing or access denied - surface the
misconfiguration instead of poking.
+ raise CloudSQLOperationError(
+ f"Cloud SQL operations.list failed for instance
{self.instance}: {e}"
+ )
+ raise
+ in_progress = [op for op in operations if op.get("status") in
CLOUD_SQL_NON_TERMINAL_STATUSES]
+ if in_progress:
+ self.log.info(
+ "%s operation(s) still in progress on instance %s.",
len(in_progress), self.instance
+ )
+ return False
+ return True
+
+ def execute(self, context: Context) -> None:
+ """Run on the worker and defer using the trigger when in deferrable
mode."""
+ if self.deferrable:
+ if not self.poke(context=context):
+ self.defer(
+ timeout=timedelta(seconds=self.timeout),
+ trigger=CloudSQLNoOperationInProgressTrigger(
+ instance=self.instance,
+ project_id=self.project_id,
+ gcp_conn_id=self.gcp_conn_id,
+ impersonation_chain=self.impersonation_chain,
+ poke_interval=int(self.poke_interval),
+ api_version=self.api_version,
+ ),
+ method_name="execute_complete",
+ )
+ else:
+ super().execute(context)
+
+ def execute_complete(self, context: Context, event: dict | None = None) ->
None:
+ """Act as a callback for when the trigger fires."""
+ if event and event.get("status") in ("failed", "error"):
+ raise CloudSQLOperationError(event["message"])
+ self.log.info("No administrative operation in progress on instance
%s.", self.instance)
diff --git
a/providers/google/src/airflow/providers/google/cloud/triggers/cloud_sql.py
b/providers/google/src/airflow/providers/google/cloud/triggers/cloud_sql.py
index dd8a482d92d..5b23ea62732 100644
--- a/providers/google/src/airflow/providers/google/cloud/triggers/cloud_sql.py
+++ b/providers/google/src/airflow/providers/google/cloud/triggers/cloud_sql.py
@@ -23,8 +23,13 @@ import asyncio
from collections.abc import Sequence
from asgiref.sync import sync_to_async
+from googleapiclient.errors import HttpError
-from airflow.providers.google.cloud.hooks.cloud_sql import CloudSQLAsyncHook,
CloudSqlOperationStatus
+from airflow.providers.google.cloud.hooks.cloud_sql import (
+ CLOUD_SQL_NON_TERMINAL_STATUSES,
+ CloudSQLAsyncHook,
+ CloudSqlOperationStatus,
+)
from airflow.providers.google.common.hooks.base_google import
PROVIDE_PROJECT_ID
from airflow.triggers.base import BaseTrigger, TriggerEvent
@@ -117,3 +122,77 @@ class CloudSQLExportTrigger(BaseTrigger):
"message": str(e),
}
)
+
+
+class CloudSQLNoOperationInProgressTrigger(BaseTrigger):
+ """
+ Trigger that waits until a Cloud SQL instance has no administrative
operation in progress.
+
+ Polls ``sqladmin.operations.list`` for the target instance and fires once
no operation is in a
+ non-terminal state (PENDING/RUNNING). Fails fast on 403/404 (the instance
is missing or access
+ is denied) rather than polling until timeout.
+ """
+
+ def __init__(
+ self,
+ instance: str,
+ project_id: str = PROVIDE_PROJECT_ID,
+ gcp_conn_id: str = "google_cloud_default",
+ impersonation_chain: str | Sequence[str] | None = None,
+ poke_interval: int = 20,
+ api_version: str = "v1beta4",
+ ):
+ super().__init__()
+ self.instance = instance
+ self.project_id = project_id
+ self.gcp_conn_id = gcp_conn_id
+ self.impersonation_chain = impersonation_chain
+ self.poke_interval = poke_interval
+ self.api_version = api_version
+ self.hook = CloudSQLAsyncHook(
+ gcp_conn_id=self.gcp_conn_id,
+ impersonation_chain=self.impersonation_chain,
+ )
+
+ def serialize(self):
+ return (
+
"airflow.providers.google.cloud.triggers.cloud_sql.CloudSQLNoOperationInProgressTrigger",
+ {
+ "instance": self.instance,
+ "project_id": self.project_id,
+ "gcp_conn_id": self.gcp_conn_id,
+ "impersonation_chain": self.impersonation_chain,
+ "poke_interval": self.poke_interval,
+ "api_version": self.api_version,
+ },
+ )
+
+ async def run(self):
+ try:
+ sync_hook = await
self.hook.get_sync_hook(api_version=self.api_version)
+ while True:
+ # No async ``operations.list`` exists on the hook, so run the
sync call in a thread.
+ operations = await sync_to_async(sync_hook.list_operations)(
+ project_id=self.project_id, instance=self.instance
+ )
+ in_progress = [op for op in operations if op.get("status") in
CLOUD_SQL_NON_TERMINAL_STATUSES]
+ if not in_progress:
+ yield TriggerEvent({"instance": self.instance, "status":
"success"})
+ return
+ self.log.info(
+ "%s operation(s) still in progress on instance %s,
sleeping for %s seconds.",
+ len(in_progress),
+ self.instance,
+ self.poke_interval,
+ )
+ await asyncio.sleep(self.poke_interval)
+ except HttpError as e:
+ if e.resp.status in (403, 404):
+ # Instance missing or access denied - no point retrying.
+ yield TriggerEvent({"status": "failed", "message": str(e)})
+ return
+ self.log.exception("Error listing operations for instance %s.",
self.instance)
+ yield TriggerEvent({"status": "failed", "message": str(e)})
+ except Exception as e:
+ self.log.exception("Error listing operations for instance %s.",
self.instance)
+ yield TriggerEvent({"status": "failed", "message": str(e)})
diff --git a/providers/google/src/airflow/providers/google/get_provider_info.py
b/providers/google/src/airflow/providers/google/get_provider_info.py
index 8806a6a31e0..8a1024cd714 100644
--- a/providers/google/src/airflow/providers/google/get_provider_info.py
+++ b/providers/google/src/airflow/providers/google/get_provider_info.py
@@ -721,6 +721,10 @@ def get_provider_info():
"integration-name": "Google Bigtable",
"python-modules":
["airflow.providers.google.cloud.sensors.bigtable"],
},
+ {
+ "integration-name": "Google Cloud SQL",
+ "python-modules":
["airflow.providers.google.cloud.sensors.cloud_sql"],
+ },
{
"integration-name": "Managed Service for Apache Airflow",
"python-modules":
["airflow.providers.google.cloud.sensors.cloud_composer"],
diff --git
a/providers/google/tests/system/google/cloud/cloud_sql/example_cloud_sql.py
b/providers/google/tests/system/google/cloud/cloud_sql/example_cloud_sql.py
index 0abc5d9f806..eda31bd43c9 100644
--- a/providers/google/tests/system/google/cloud/cloud_sql/example_cloud_sql.py
+++ b/providers/google/tests/system/google/cloud/cloud_sql/example_cloud_sql.py
@@ -46,6 +46,7 @@ from airflow.providers.google.cloud.operators.gcs import (
GCSDeleteBucketOperator,
GCSObjectCreateAclEntryOperator,
)
+from airflow.providers.google.cloud.sensors.cloud_sql import
CloudSQLNoOperationInProgressSensor
try:
from airflow.sdk import TriggerRule
@@ -228,6 +229,15 @@ with DAG(
)
# [END howto_operator_cloudsql_import_gcs_permissions]
+ # Cloud SQL serializes admin operations per instance, so wait until the
export above has
+ # finished (no operation in progress) before submitting the import to
avoid a 409.
+ # [START howto_sensor_cloudsql_no_operation_in_progress]
+ sql_wait_no_operation_task = CloudSQLNoOperationInProgressSensor(
+ instance=INSTANCE_NAME,
+ task_id="sql_wait_no_operation_task",
+ )
+ # [END howto_sensor_cloudsql_no_operation_in_progress]
+
# [START howto_operator_cloudsql_import]
sql_import_task = CloudSQLImportInstanceOperator(
body=import_body, instance=INSTANCE_NAME, task_id="sql_import_task"
@@ -288,6 +298,7 @@ with DAG(
>> sql_export_task
>> sql_export_def_task
>> sql_gcp_add_object_permission_task
+ >> sql_wait_no_operation_task
>> sql_import_task
>> sql_instance_clone
>> sql_db_delete_task
diff --git a/providers/google/tests/unit/google/cloud/hooks/test_cloud_sql.py
b/providers/google/tests/unit/google/cloud/hooks/test_cloud_sql.py
index 54480d17118..518f509e522 100644
--- a/providers/google/tests/unit/google/cloud/hooks/test_cloud_sql.py
+++ b/providers/google/tests/unit/google/cloud/hooks/test_cloud_sql.py
@@ -543,6 +543,26 @@ class TestGcpSqlHookDefaultProjectId:
get_method.assert_called_once_with(project="gcp-project",
operation="operation_id")
execute_method.assert_called_once_with(num_retries=self.cloudsql_hook.num_retries)
+
@mock.patch("airflow.providers.google.cloud.hooks.cloud_sql.CloudSQLHook.get_conn")
+ def test_list_operations(self, mock_get_conn):
+ operations_method = mock_get_conn.return_value.operations
+ list_method = operations_method.return_value.list
+ execute_method = list_method.return_value.execute
+ execute_method.return_value = {
+ "items": [
+ {"name": "op1", "status": "DONE", "targetId": "my-instance"},
+ {"name": "op2", "status": "RUNNING", "targetId":
"other-instance"},
+ ]
+ }
+
+ result = self.cloudsql_hook.list_operations(project_id="gcp-project",
instance="my-instance")
+
+ # Only operations whose targetId matches the instance are returned.
+ assert result == [{"name": "op1", "status": "DONE", "targetId":
"my-instance"}]
+ operations_method.assert_called_once()
+ list_method.assert_called_once_with(project="gcp-project",
instance="my-instance", maxResults=None)
+
execute_method.assert_called_once_with(num_retries=self.cloudsql_hook.num_retries)
+
@mock.patch("airflow.providers.google.cloud.hooks.cloud_sql.build")
def test_get_conn_obj_caching(self, mock_build):
self.cloudsql_hook._authorize = mock.MagicMock()
diff --git a/providers/google/tests/unit/google/cloud/sensors/test_cloud_sql.py
b/providers/google/tests/unit/google/cloud/sensors/test_cloud_sql.py
new file mode 100644
index 00000000000..4f28c9482d0
--- /dev/null
+++ b/providers/google/tests/unit/google/cloud/sensors/test_cloud_sql.py
@@ -0,0 +1,127 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements. See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership. The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License. You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied. See the License for the
+# specific language governing permissions and limitations
+# under the License.
+from __future__ import annotations
+
+from unittest import mock
+
+import httplib2
+import pytest
+from googleapiclient.errors import HttpError
+
+from airflow.providers.common.compat.sdk import AirflowException, TaskDeferred
+from airflow.providers.google.cloud.sensors.cloud_sql import
CloudSQLNoOperationInProgressSensor
+from airflow.providers.google.cloud.triggers.cloud_sql import
CloudSQLNoOperationInProgressTrigger
+
+SENSOR_PATH = "airflow.providers.google.cloud.sensors.cloud_sql.{}"
+TASK_ID = "test_no_op_in_progress"
+INSTANCE = "test-instance"
+PROJECT_ID = "test-project"
+
+
+def _http_error(status: int) -> HttpError:
+ return HttpError(resp=httplib2.Response({"status": status}),
content=b"error content")
+
+
+class TestCloudSQLNoOperationInProgressSensor:
+ @mock.patch(SENSOR_PATH.format("CloudSQLHook"), autospec=True)
+ def test_poke_returns_true_when_no_in_progress_operations(self, mock_hook):
+ mock_hook.return_value.list_operations.return_value = [
+ {"name": "op1", "status": "DONE", "targetId": INSTANCE},
+ ]
+ sensor = CloudSQLNoOperationInProgressSensor(
+ task_id=TASK_ID, instance=INSTANCE, project_id=PROJECT_ID
+ )
+ assert sensor.poke(context={}) is True
+ mock_hook.return_value.list_operations.assert_called_once_with(
+ project_id=PROJECT_ID, instance=INSTANCE
+ )
+
+ @mock.patch(SENSOR_PATH.format("CloudSQLHook"), autospec=True)
+ def test_poke_returns_false_when_operation_in_progress(self, mock_hook):
+ mock_hook.return_value.list_operations.return_value = [
+ {"name": "op1", "status": "RUNNING", "targetId": INSTANCE},
+ {"name": "op2", "status": "PENDING", "targetId": INSTANCE},
+ ]
+ sensor = CloudSQLNoOperationInProgressSensor(
+ task_id=TASK_ID, instance=INSTANCE, project_id=PROJECT_ID
+ )
+ assert sensor.poke(context={}) is False
+
+ @pytest.mark.parametrize("status", [403, 404])
+ @mock.patch(SENSOR_PATH.format("CloudSQLHook"), autospec=True)
+ def test_poke_fails_fast_on_403_404(self, mock_hook, status):
+ mock_hook.return_value.list_operations.side_effect =
_http_error(status)
+ sensor = CloudSQLNoOperationInProgressSensor(
+ task_id=TASK_ID, instance=INSTANCE, project_id=PROJECT_ID
+ )
+ with pytest.raises(AirflowException, match="operations.list failed"):
+ sensor.poke(context={})
+
+ @mock.patch(SENSOR_PATH.format("CloudSQLHook"), autospec=True)
+ def test_poke_reraises_other_http_errors(self, mock_hook):
+ mock_hook.return_value.list_operations.side_effect = _http_error(500)
+ sensor = CloudSQLNoOperationInProgressSensor(
+ task_id=TASK_ID, instance=INSTANCE, project_id=PROJECT_ID
+ )
+ with pytest.raises(HttpError):
+ sensor.poke(context={})
+
+ @mock.patch(SENSOR_PATH.format("CloudSQLHook"), autospec=True)
+ def test_execute_defers_when_deferrable_and_not_idle(self, mock_hook):
+ mock_hook.return_value.list_operations.return_value = [
+ {"name": "op1", "status": "RUNNING", "targetId": INSTANCE},
+ ]
+ sensor = CloudSQLNoOperationInProgressSensor(
+ task_id=TASK_ID, instance=INSTANCE, project_id=PROJECT_ID,
deferrable=True
+ )
+ with pytest.raises(TaskDeferred) as exc:
+ sensor.execute(context={})
+ assert isinstance(exc.value.trigger,
CloudSQLNoOperationInProgressTrigger)
+ assert exc.value.method_name == "execute_complete"
+
+ @mock.patch(SENSOR_PATH.format("CloudSQLHook"), autospec=True)
+ def test_execute_does_not_defer_when_idle(self, mock_hook):
+ mock_hook.return_value.list_operations.return_value = [
+ {"name": "op1", "status": "DONE", "targetId": INSTANCE},
+ ]
+ sensor = CloudSQLNoOperationInProgressSensor(
+ task_id=TASK_ID, instance=INSTANCE, project_id=PROJECT_ID,
deferrable=True
+ )
+ assert sensor.execute(context={}) is None
+
+ @mock.patch(SENSOR_PATH.format("BaseSensorOperator.execute"),
autospec=True)
+ @mock.patch(SENSOR_PATH.format("CloudSQLHook"), autospec=True)
+ def test_execute_non_deferrable_delegates_to_super(self, mock_hook,
mock_super_execute):
+ sensor = CloudSQLNoOperationInProgressSensor(
+ task_id=TASK_ID, instance=INSTANCE, project_id=PROJECT_ID,
deferrable=False
+ )
+ sensor.execute(context={})
+ mock_super_execute.assert_called_once()
+
+ def test_execute_complete_success(self):
+ sensor = CloudSQLNoOperationInProgressSensor(
+ task_id=TASK_ID, instance=INSTANCE, project_id=PROJECT_ID
+ )
+ assert sensor.execute_complete(context={}, event={"instance":
INSTANCE, "status": "success"}) is None
+
+ @pytest.mark.parametrize("status", ["failed", "error"])
+ def test_execute_complete_failure_raises(self, status):
+ sensor = CloudSQLNoOperationInProgressSensor(
+ task_id=TASK_ID, instance=INSTANCE, project_id=PROJECT_ID
+ )
+ with pytest.raises(AirflowException, match="boom"):
+ sensor.execute_complete(context={}, event={"status": status,
"message": "boom"})
diff --git
a/providers/google/tests/unit/google/cloud/triggers/test_cloud_sql.py
b/providers/google/tests/unit/google/cloud/triggers/test_cloud_sql.py
index f5de135c47d..fe8e501ef27 100644
--- a/providers/google/tests/unit/google/cloud/triggers/test_cloud_sql.py
+++ b/providers/google/tests/unit/google/cloud/triggers/test_cloud_sql.py
@@ -20,12 +20,20 @@ import asyncio
import logging
from unittest import mock
+import httplib2
import pytest
+from googleapiclient.errors import HttpError
from airflow.providers.google.cloud.hooks.cloud_sql import CloudSQLHook
-from airflow.providers.google.cloud.triggers.cloud_sql import
CloudSQLExportTrigger
+from airflow.providers.google.cloud.triggers.cloud_sql import (
+ CloudSQLExportTrigger,
+ CloudSQLNoOperationInProgressTrigger,
+)
from airflow.triggers.base import TriggerEvent
+INSTANCE = "test-instance"
+NO_OP_CLASSPATH =
"airflow.providers.google.cloud.triggers.cloud_sql.CloudSQLNoOperationInProgressTrigger"
+
CLASSPATH =
"airflow.providers.google.cloud.triggers.cloud_sql.CloudSQLExportTrigger"
TASK_ID = "test_task"
TEST_POLL_INTERVAL = 10
@@ -197,3 +205,75 @@ class TestCloudSQLExportTrigger:
# Verify the default universe branch not being called
mock_async_get_op.assert_not_called()
task.cancel()
+
+
[email protected]
+def no_op_trigger():
+ return CloudSQLNoOperationInProgressTrigger(
+ instance=INSTANCE,
+ project_id=PROJECT_ID,
+ impersonation_chain=None,
+ gcp_conn_id=TEST_GCP_CONN_ID,
+ poke_interval=TEST_POLL_INTERVAL,
+ api_version=API_VERSION,
+ )
+
+
+class TestCloudSQLNoOperationInProgressTrigger:
+ def test_serialization(self, no_op_trigger, sync_hook_mock):
+ classpath, kwargs = no_op_trigger.serialize()
+ assert classpath == NO_OP_CLASSPATH
+ assert kwargs == {
+ "instance": INSTANCE,
+ "project_id": PROJECT_ID,
+ "impersonation_chain": None,
+ "gcp_conn_id": TEST_GCP_CONN_ID,
+ "poke_interval": TEST_POLL_INTERVAL,
+ "api_version": API_VERSION,
+ }
+
+ @pytest.mark.asyncio
+ async def test_run_success_when_no_operation_in_progress(self,
no_op_trigger, sync_hook_mock):
+ sync_hook_mock.list_operations.return_value = [
+ {"name": "op1", "status": "DONE", "targetId": INSTANCE},
+ ]
+ actual = await no_op_trigger.run().asend(None)
+ assert actual == TriggerEvent({"instance": INSTANCE, "status":
"success"})
+
+ @pytest.mark.asyncio
+ async def test_run_success_when_only_terminal_operations(self,
no_op_trigger, sync_hook_mock):
+ # Operations in terminal states (DONE) do not block; the trigger keys
off the non-terminal set.
+ sync_hook_mock.list_operations.return_value = [
+ {"name": "op-done", "status": "DONE", "targetId": INSTANCE},
+ {"name": "op-unknown", "status": "UNKNOWN", "targetId": INSTANCE},
+ ]
+ actual = await no_op_trigger.run().asend(None)
+ assert actual == TriggerEvent({"instance": INSTANCE, "status":
"success"})
+
+ @pytest.mark.asyncio
+ @mock.patch(
+ "airflow.providers.google.cloud.triggers.cloud_sql.asyncio.sleep",
new_callable=mock.AsyncMock
+ )
+ async def test_run_sleeps_while_operation_in_progress(self, mock_sleep,
no_op_trigger, sync_hook_mock):
+ sync_hook_mock.list_operations.side_effect = [
+ [{"name": "op1", "status": "RUNNING", "targetId": INSTANCE}],
+ [{"name": "op2", "status": "DONE", "targetId": INSTANCE}],
+ ]
+ actual = await no_op_trigger.run().asend(None)
+ assert actual == TriggerEvent({"instance": INSTANCE, "status":
"success"})
+ mock_sleep.assert_awaited_once_with(TEST_POLL_INTERVAL)
+
+ @pytest.mark.asyncio
+ @pytest.mark.parametrize("status", [403, 404])
+ async def test_run_fails_fast_on_403_404(self, status, no_op_trigger,
sync_hook_mock):
+ sync_hook_mock.list_operations.side_effect = HttpError(
+ resp=httplib2.Response({"status": status}), content=b"denied or
missing"
+ )
+ actual = await no_op_trigger.run().asend(None)
+ assert actual.payload["status"] == "failed"
+
+ @pytest.mark.asyncio
+ async def test_run_fails_on_generic_exception(self, no_op_trigger,
sync_hook_mock):
+ sync_hook_mock.list_operations.side_effect = Exception("boom")
+ actual = await no_op_trigger.run().asend(None)
+ assert actual == TriggerEvent({"status": "failed", "message": "boom"})