This is an automated email from the ASF dual-hosted git repository.
vincbeck 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 1fd77cdd428 Make RedshiftDeleteClusterOperator delete reliably during
cluster transitions (#69574)
1fd77cdd428 is described below
commit 1fd77cdd4282a53be1103c15c1f8976f03831b45
Author: Sean Ghaeli <[email protected]>
AuthorDate: Wed Jul 22 12:44:58 2026 -0700
Make RedshiftDeleteClusterOperator delete reliably during cluster
transitions (#69574)
A delete issued while the cluster is mid-transition
(pausing/resuming/resizing)
raises InvalidClusterStateFault. The retry budget was hardcoded to 10 * 15s
=
2.5 min, which expires long before a real transition settles (~minutes), so
the
task fails and the cluster leaks.
Non-deferrable mode: raise the synchronous busy-retry budget to 60 * 15s =
~15
min so it outlasts a transition; still fail-loud once exhausted.
Deferrable mode: previously the synchronous loop ran before the defer,
blocking
the worker. Now attempt the delete once; on InvalidClusterStateFault defer
to a
new RedshiftClusterSettledTrigger (an AwsBaseWaiterTrigger backed by a
custom
cluster_deletable waiter that treats transitional states as retry and fires
once
the cluster is deletable), then a callback re-issues the delete and defers
to the
existing RedshiftDeleteClusterTrigger; re-defers on a race. Bounded by the
existing poll_interval/max_attempts. This mirrors the EKS deferrable
re-defer
pattern and the AwsBaseWaiterTrigger convention used by the other Redshift
triggers. No worker is blocked in deferrable mode.
---
docs/spelling_wordlist.txt | 1 +
.../amazon/aws/operators/redshift_cluster.py | 122 +++++++++++-----
.../amazon/aws/triggers/redshift_cluster.py | 54 ++++++++
.../providers/amazon/aws/waiters/redshift.json | 55 ++++++++
.../amazon/aws/operators/test_redshift_cluster.py | 153 +++++++++++++++++++--
.../amazon/aws/triggers/test_redshift_cluster.py | 36 ++++-
6 files changed, 369 insertions(+), 52 deletions(-)
diff --git a/docs/spelling_wordlist.txt b/docs/spelling_wordlist.txt
index cf74e1f5f07..2a8b7c94591 100644
--- a/docs/spelling_wordlist.txt
+++ b/docs/spelling_wordlist.txt
@@ -452,6 +452,7 @@ deferrable
deidentify
DeidentifyTemplate
del
+deletable
DeleteAgentResponse
DeleteAgentVersionResponse
delim
diff --git
a/providers/amazon/src/airflow/providers/amazon/aws/operators/redshift_cluster.py
b/providers/amazon/src/airflow/providers/amazon/aws/operators/redshift_cluster.py
index ea0d1272db8..2424e35e0ca 100644
---
a/providers/amazon/src/airflow/providers/amazon/aws/operators/redshift_cluster.py
+++
b/providers/amazon/src/airflow/providers/amazon/aws/operators/redshift_cluster.py
@@ -27,6 +27,7 @@ from tenacity import Retrying, retry_if_exception,
stop_after_delay, wait_fixed
from airflow.providers.amazon.aws.hooks.redshift_cluster import RedshiftHook
from airflow.providers.amazon.aws.operators.base_aws import AwsBaseOperator
from airflow.providers.amazon.aws.triggers.redshift_cluster import (
+ RedshiftClusterSettledTrigger,
RedshiftCreateClusterSnapshotTrigger,
RedshiftCreateClusterTrigger,
RedshiftDeleteClusterTrigger,
@@ -836,7 +837,9 @@ class
RedshiftDeleteClusterOperator(AwsBaseOperator[RedshiftHook]):
https://boto3.amazonaws.com/v1/documentation/api/latest/reference/core/session.html
:param poll_interval: Time (in seconds) to wait between two consecutive
calls to check cluster state
:param deferrable: Run operator in the deferrable mode.
- :param max_attempts: (Deferrable mode only) The maximum number of attempts
to be made
+ :param max_attempts: The maximum number of attempts to be made, both when
retrying the delete while
+ the cluster is busy and when waiting for deletion to complete;
combined with ``poll_interval``
+ the default gives a ~15 minute window, long enough to outlast a
pause/resize.
"""
template_fields: Sequence[str] = aws_template_fields(
@@ -864,16 +867,18 @@ class
RedshiftDeleteClusterOperator(AwsBaseOperator[RedshiftHook]):
self.final_cluster_snapshot_identifier =
final_cluster_snapshot_identifier
self.wait_for_completion = wait_for_completion
self.poll_interval = poll_interval
- # These parameters are added to keep trying if there is a running
operation in the cluster
- # If there is a running operation in the cluster while trying to
delete it, a InvalidClusterStateFault
- # is thrown. In such case, retrying
- self._attempts = 10
- self._attempt_interval = 15
self.deferrable = deferrable
self.max_attempts = max_attempts
def execute(self, context: Context):
- while self._attempts:
+ if self.deferrable:
+ self._delete_or_defer_until_settled()
+ return
+
+ # Retry the delete while the cluster is mid-transition
(InvalidClusterStateFault) until it
+ # settles into a deletable state.
+ attempts = self.max_attempts
+ while attempts:
try:
self.hook.delete_cluster(
cluster_identifier=self.cluster_identifier,
@@ -882,51 +887,98 @@ class
RedshiftDeleteClusterOperator(AwsBaseOperator[RedshiftHook]):
)
break
except self.hook.conn.exceptions.InvalidClusterStateFault:
- self._attempts -= 1
+ attempts -= 1
- if self._attempts:
+ if attempts:
current_state = self.hook.conn.describe_clusters(
ClusterIdentifier=self.cluster_identifier
)["Clusters"][0]["ClusterStatus"]
self.log.error(
"Cluster in %s state, unable to delete. %d attempts
remaining.",
current_state,
- self._attempts,
+ attempts,
)
- time.sleep(self._attempt_interval)
+ time.sleep(self.poll_interval)
else:
raise
- if self.deferrable:
- cluster_state =
self.hook.cluster_status(cluster_identifier=self.cluster_identifier)
- if cluster_state == "cluster_not_found":
- self.log.info("Cluster deleted successfully")
- elif cluster_state in ("creating", "modifying"):
- raise AirflowException(
- f"Unable to delete cluster since cluster is currently in
status: {cluster_state}"
- )
- else:
- self.defer(
- timeout=timedelta(seconds=self.max_attempts *
self.poll_interval + 60),
- trigger=RedshiftDeleteClusterTrigger(
- cluster_identifier=self.cluster_identifier,
- waiter_delay=self.poll_interval,
- waiter_max_attempts=self.max_attempts,
- aws_conn_id=self.aws_conn_id,
- region_name=self.region_name,
- verify=self.verify,
- botocore_config=self.botocore_config,
- ),
- method_name="execute_complete",
- )
-
- elif self.wait_for_completion:
+ if self.wait_for_completion:
waiter = self.hook.conn.get_waiter("cluster_deleted")
waiter.wait(
ClusterIdentifier=self.cluster_identifier,
WaiterConfig={"Delay": self.poll_interval, "MaxAttempts":
self.max_attempts},
)
+ def _delete_or_defer_until_settled(self) -> None:
+ """
+ Issue the delete once (deferrable mode), then defer.
+
+ If accepted, defer to :class:`RedshiftDeleteClusterTrigger` to await
deletion. If the cluster is
+ busy (``InvalidClusterStateFault``), defer to
:class:`RedshiftClusterSettledTrigger`; the
+ ``_retry_delete_when_settled`` callback re-issues the delete once it
settles.
+ """
+ try:
+ self.hook.delete_cluster(
+ cluster_identifier=self.cluster_identifier,
+ skip_final_cluster_snapshot=self.skip_final_cluster_snapshot,
+
final_cluster_snapshot_identifier=self.final_cluster_snapshot_identifier,
+ )
+ except self.hook.conn.exceptions.InvalidClusterStateFault:
+ self.log.info(
+ "Cluster %s is busy; deferring until it settles into a
deletable state.",
+ self.cluster_identifier,
+ )
+ self.defer(
+ timeout=timedelta(seconds=self.max_attempts *
self.poll_interval + 60),
+ trigger=RedshiftClusterSettledTrigger(
+ cluster_identifier=self.cluster_identifier,
+ waiter_delay=self.poll_interval,
+ waiter_max_attempts=self.max_attempts,
+ aws_conn_id=self.aws_conn_id,
+ region_name=self.region_name,
+ verify=self.verify,
+ botocore_config=self.botocore_config,
+ ),
+ method_name="_retry_delete_when_settled",
+ )
+ return
+
+ self._defer_until_deleted()
+
+ def _defer_until_deleted(self) -> None:
+ """Defer to the delete-completion waiter, short-circuiting if the
cluster is already gone."""
+ cluster_state =
self.hook.cluster_status(cluster_identifier=self.cluster_identifier)
+ if cluster_state == "cluster_not_found":
+ self.log.info("Cluster deleted successfully")
+ return
+ self.defer(
+ timeout=timedelta(seconds=self.max_attempts * self.poll_interval +
60),
+ trigger=RedshiftDeleteClusterTrigger(
+ cluster_identifier=self.cluster_identifier,
+ waiter_delay=self.poll_interval,
+ waiter_max_attempts=self.max_attempts,
+ aws_conn_id=self.aws_conn_id,
+ region_name=self.region_name,
+ verify=self.verify,
+ botocore_config=self.botocore_config,
+ ),
+ method_name="execute_complete",
+ )
+
+ def _retry_delete_when_settled(self, context: Context, event: dict[str,
Any] | None = None) -> None:
+ """
+ Re-issue the delete once the cluster has settled, then defer until
deletion completes.
+
+ Callback for :class:`RedshiftClusterSettledTrigger`. If the delete is
still rejected because of a
+ race (the cluster re-entered a transitional state), defer to the
settle-wait trigger again.
+ """
+ validated_event = validate_execute_complete_event(event)
+ if validated_event["status"] != "success":
+ raise AirflowException(f"Error waiting for cluster to become
deletable: {validated_event}")
+
+ self.cluster_identifier = validated_event["cluster_identifier"]
+ self._delete_or_defer_until_settled()
+
def execute_complete(self, context: Context, event: dict[str, Any] | None
= None) -> None:
validated_event = validate_execute_complete_event(event)
diff --git
a/providers/amazon/src/airflow/providers/amazon/aws/triggers/redshift_cluster.py
b/providers/amazon/src/airflow/providers/amazon/aws/triggers/redshift_cluster.py
index ebd32a2b423..8f43ef2e4f7 100644
---
a/providers/amazon/src/airflow/providers/amazon/aws/triggers/redshift_cluster.py
+++
b/providers/amazon/src/airflow/providers/amazon/aws/triggers/redshift_cluster.py
@@ -343,3 +343,57 @@ class RedshiftClusterTrigger(BaseTrigger):
await asyncio.sleep(self.poke_interval)
except Exception as e:
yield TriggerEvent({"status": "error", "message": str(e)})
+
+
+class RedshiftClusterSettledTrigger(AwsBaseWaiterTrigger):
+ """
+ Wait until a Redshift cluster settles into a non-transitional (deletable)
lifecycle.
+
+ A ``delete_cluster`` call is rejected with ``InvalidClusterStateFault``
while an operation is in
+ flight (e.g. a pause or resize). Because a busy cluster can settle into
*different* terminal states
+ depending on the in-flight operation (a ``pausing`` cluster becomes
``paused``; a ``resizing`` cluster
+ becomes ``available``), this trigger uses the custom ``cluster_deletable``
waiter, which has one
+ ``success`` acceptor per deletable ``ClusterStatus`` value rather than a
single target state.
+
+ :param cluster_identifier: unique identifier of a cluster
+ :param waiter_delay: The amount of time in seconds to wait between
attempts.
+ :param waiter_max_attempts: The maximum number of attempts to be made.
+ :param aws_conn_id: The Airflow connection used for AWS credentials.
+ :param region_name: The AWS region where the cluster is. Used to build the
hook.
+ :param verify: Whether or not to verify SSL certificates. Used to build
the hook.
+ :param botocore_config: Configuration dictionary for the botocore client.
Used to build the hook.
+ """
+
+ def __init__(
+ self,
+ *,
+ cluster_identifier: str,
+ aws_conn_id: str | None = "aws_default",
+ region_name: str | None = None,
+ waiter_delay: int = 30,
+ waiter_max_attempts: int = 30,
+ **kwargs,
+ ):
+ super().__init__(
+ serialized_fields={"cluster_identifier": cluster_identifier},
+ waiter_name="cluster_deletable",
+ waiter_args={"ClusterIdentifier": cluster_identifier},
+ failure_message="Error while waiting for the redshift cluster to
become deletable",
+ status_message="Waiting for redshift cluster to settle into a
deletable state",
+ status_queries=["Clusters[].ClusterStatus"],
+ return_key="cluster_identifier",
+ return_value=cluster_identifier,
+ waiter_delay=waiter_delay,
+ waiter_max_attempts=waiter_max_attempts,
+ aws_conn_id=aws_conn_id,
+ region_name=region_name,
+ **kwargs,
+ )
+
+ def hook(self) -> AwsGenericHook:
+ return RedshiftHook(
+ aws_conn_id=self.aws_conn_id,
+ region_name=self.region_name,
+ verify=self.verify,
+ config=self.botocore_config,
+ )
diff --git
a/providers/amazon/src/airflow/providers/amazon/aws/waiters/redshift.json
b/providers/amazon/src/airflow/providers/amazon/aws/waiters/redshift.json
index 8165eb3fc43..389da67068e 100644
--- a/providers/amazon/src/airflow/providers/amazon/aws/waiters/redshift.json
+++ b/providers/amazon/src/airflow/providers/amazon/aws/waiters/redshift.json
@@ -50,6 +50,61 @@
"state": "failure"
}
]
+ },
+ "cluster_deletable": {
+ "operation": "DescribeClusters",
+ "delay": 30,
+ "maxAttempts": 60,
+ "acceptors": [
+ {
+ "matcher": "pathAll",
+ "argument": "Clusters[].ClusterStatus",
+ "expected": "available",
+ "state": "success"
+ },
+ {
+ "matcher": "pathAll",
+ "argument": "Clusters[].ClusterStatus",
+ "expected": "paused",
+ "state": "success"
+ },
+ {
+ "matcher": "pathAll",
+ "argument": "Clusters[].ClusterStatus",
+ "expected": "incompatible-hsm",
+ "state": "success"
+ },
+ {
+ "matcher": "pathAll",
+ "argument": "Clusters[].ClusterStatus",
+ "expected": "incompatible-restore",
+ "state": "success"
+ },
+ {
+ "matcher": "pathAll",
+ "argument": "Clusters[].ClusterStatus",
+ "expected": "incompatible-network",
+ "state": "success"
+ },
+ {
+ "matcher": "pathAll",
+ "argument": "Clusters[].ClusterStatus",
+ "expected": "incompatible-parameters",
+ "state": "success"
+ },
+ {
+ "matcher": "pathAll",
+ "argument": "Clusters[].ClusterStatus",
+ "expected": "hardware-failure",
+ "state": "success"
+ },
+ {
+ "matcher": "error",
+ "argument": "Clusters[].ClusterStatus",
+ "expected": "ClusterNotFound",
+ "state": "success"
+ }
+ ]
}
}
}
diff --git
a/providers/amazon/tests/unit/amazon/aws/operators/test_redshift_cluster.py
b/providers/amazon/tests/unit/amazon/aws/operators/test_redshift_cluster.py
index db6466125a0..46d25b8ab33 100644
--- a/providers/amazon/tests/unit/amazon/aws/operators/test_redshift_cluster.py
+++ b/providers/amazon/tests/unit/amazon/aws/operators/test_redshift_cluster.py
@@ -34,6 +34,7 @@ from airflow.providers.amazon.aws.operators.redshift_cluster
import (
RedshiftResumeClusterOperator,
)
from airflow.providers.amazon.aws.triggers.redshift_cluster import (
+ RedshiftClusterSettledTrigger,
RedshiftCreateClusterSnapshotTrigger,
RedshiftDeleteClusterTrigger,
RedshiftPauseClusterTrigger,
@@ -781,18 +782,19 @@ class TestDeleteClusterOperator:
cluster_identifier="test_cluster",
aws_conn_id="aws_conn_test",
wait_for_completion=False,
+ max_attempts=3,
)
with pytest.raises(returned_exception):
redshift_operator.execute(None)
- assert mock_delete_cluster.call_count == 10
+ assert mock_delete_cluster.call_count == 3
@mock.patch("airflow.providers.amazon.aws.hooks.redshift_cluster.RedshiftHook.cluster_status")
@mock.patch("airflow.providers.amazon.aws.hooks.redshift_cluster.RedshiftHook.delete_cluster")
def test_delete_cluster_deferrable_mode(self, mock_delete_cluster,
mock_cluster_status):
- """Test delete cluster operator with defer when deferrable param is
true"""
+ """When the delete is accepted, deferrable mode waits for deletion to
complete."""
mock_delete_cluster.return_value = True
- mock_cluster_status.return_value = "available"
+ mock_cluster_status.return_value = "deleting"
delete_cluster = RedshiftDeleteClusterOperator(
task_id="task_test",
cluster_identifier="test_cluster",
@@ -806,16 +808,59 @@ class TestDeleteClusterOperator:
assert isinstance(exc.value.trigger, RedshiftDeleteClusterTrigger), (
"Trigger is not a RedshiftDeleteClusterTrigger"
)
+ # Delete is attempted exactly once (no synchronous busy-retry loop in
deferrable mode).
+ mock_delete_cluster.assert_called_once()
-
@mock.patch("airflow.providers.amazon.aws.operators.redshift_cluster.RedshiftDeleteClusterOperator.defer")
@mock.patch("airflow.providers.amazon.aws.hooks.redshift_cluster.RedshiftHook.cluster_status")
@mock.patch("airflow.providers.amazon.aws.hooks.redshift_cluster.RedshiftHook.delete_cluster")
- def test_delete_cluster_deferrable_mode_in_paused_state(
- self, mock_delete_cluster, mock_cluster_status, mock_defer
+ def test_delete_cluster_deferrable_mode_already_gone(self,
mock_delete_cluster, mock_cluster_status):
+ """When the cluster is already gone after the delete, deferrable mode
completes without deferring."""
+ mock_delete_cluster.return_value = True
+ mock_cluster_status.return_value = "cluster_not_found"
+ delete_cluster = RedshiftDeleteClusterOperator(
+ task_id="task_test",
+ cluster_identifier="test_cluster",
+ deferrable=True,
+ wait_for_completion=False,
+ )
+
+ # No TaskDeferred is raised; the operator returns normally.
+ delete_cluster.execute(context=None)
+ mock_delete_cluster.assert_called_once()
+
+ @mock.patch.object(RedshiftHook, "conn")
+
@mock.patch("airflow.providers.amazon.aws.hooks.redshift_cluster.RedshiftHook.delete_cluster")
+ def test_delete_cluster_deferrable_mode_busy_defers_to_settle_trigger(
+ self, mock_delete_cluster, mock_conn
):
- """Test delete cluster operator with defer when deferrable param is
true"""
+ """A busy cluster (InvalidClusterStateFault) defers to the settle-wait
trigger, not a sync loop."""
+ exception =
boto3.client("redshift").exceptions.InvalidClusterStateFault({}, "test")
+ mock_conn.exceptions.InvalidClusterStateFault = type(exception)
+ mock_delete_cluster.side_effect = exception
+
+ delete_cluster = RedshiftDeleteClusterOperator(
+ task_id="task_test",
+ cluster_identifier="test_cluster",
+ deferrable=True,
+ wait_for_completion=False,
+ )
+
+ with pytest.raises(TaskDeferred) as exc:
+ delete_cluster.execute(context=None)
+
+ assert isinstance(exc.value.trigger, RedshiftClusterSettledTrigger), (
+ "Trigger is not a RedshiftClusterSettledTrigger"
+ )
+ assert exc.value.method_name == "_retry_delete_when_settled"
+ # Delete attempted once; the synchronous busy-retry loop never runs in
deferrable mode.
+ mock_delete_cluster.assert_called_once()
+
+
@mock.patch("airflow.providers.amazon.aws.hooks.redshift_cluster.RedshiftHook.cluster_status")
+
@mock.patch("airflow.providers.amazon.aws.hooks.redshift_cluster.RedshiftHook.delete_cluster")
+ def test_retry_delete_when_settled_reissues_delete(self,
mock_delete_cluster, mock_cluster_status):
+ """The settle-wait callback re-issues the delete and defers to the
delete-complete trigger."""
mock_delete_cluster.return_value = True
- mock_cluster_status.return_value = "creating"
+ mock_cluster_status.return_value = "deleting"
delete_cluster = RedshiftDeleteClusterOperator(
task_id="task_test",
cluster_identifier="test_cluster",
@@ -823,10 +868,98 @@ class TestDeleteClusterOperator:
wait_for_completion=False,
)
+ with pytest.raises(TaskDeferred) as exc:
+ delete_cluster._retry_delete_when_settled(
+ context=None, event={"status": "success",
"cluster_identifier": "test_cluster"}
+ )
+
+ assert isinstance(exc.value.trigger, RedshiftDeleteClusterTrigger)
+ mock_delete_cluster.assert_called_once()
+
+
@mock.patch("airflow.providers.amazon.aws.hooks.redshift_cluster.RedshiftHook.cluster_status")
+
@mock.patch("airflow.providers.amazon.aws.hooks.redshift_cluster.RedshiftHook.delete_cluster")
+ def test_retry_delete_when_settled_uses_cluster_id_from_event(
+ self, mock_delete_cluster, mock_cluster_status
+ ):
+ """The re-issued delete targets the identifier from the trigger event,
not the operator field."""
+ mock_delete_cluster.return_value = True
+ mock_cluster_status.return_value = "cluster_not_found"
+ delete_cluster = RedshiftDeleteClusterOperator(
+ task_id="task_test",
+ cluster_identifier="rerendered_different_value",
+ deferrable=True,
+ wait_for_completion=False,
+ )
+
+ delete_cluster._retry_delete_when_settled(
+ context=None, event={"status": "success", "cluster_identifier":
"original_cluster"}
+ )
+
+ mock_delete_cluster.assert_called_once_with(
+ cluster_identifier="original_cluster",
+ skip_final_cluster_snapshot=True,
+ final_cluster_snapshot_identifier=None,
+ )
+
+ @mock.patch.object(RedshiftHook, "conn")
+
@mock.patch("airflow.providers.amazon.aws.hooks.redshift_cluster.RedshiftHook.delete_cluster")
+ def test_retry_delete_when_settled_redefers_on_race(self,
mock_delete_cluster, mock_conn):
+ """If the cluster re-enters a transitional state (race), the callback
re-defers to settle-wait."""
+ exception =
boto3.client("redshift").exceptions.InvalidClusterStateFault({}, "test")
+ mock_conn.exceptions.InvalidClusterStateFault = type(exception)
+ mock_delete_cluster.side_effect = exception
+
+ delete_cluster = RedshiftDeleteClusterOperator(
+ task_id="task_test",
+ cluster_identifier="test_cluster",
+ deferrable=True,
+ wait_for_completion=False,
+ )
+
+ with pytest.raises(TaskDeferred) as exc:
+ delete_cluster._retry_delete_when_settled(
+ context=None, event={"status": "success",
"cluster_identifier": "test_cluster"}
+ )
+
+ assert isinstance(exc.value.trigger, RedshiftClusterSettledTrigger)
+ assert exc.value.method_name == "_retry_delete_when_settled"
+
+ def test_retry_delete_when_settled_error_event_raises(self):
+ """A non-success event from the settle-wait trigger raises
AirflowException."""
+ delete_cluster = RedshiftDeleteClusterOperator(
+ task_id="task_test",
+ cluster_identifier="test_cluster",
+ deferrable=True,
+ wait_for_completion=False,
+ )
with pytest.raises(AirflowException):
- delete_cluster.execute(context=None)
+ delete_cluster._retry_delete_when_settled(
+ context=None, event={"status": "error", "message": "timed out"}
+ )
- assert not mock_defer.called
+ @mock.patch.object(RedshiftHook, "delete_cluster")
+ @mock.patch.object(RedshiftHook, "conn")
+ @mock.patch("time.sleep", return_value=None)
+ def test_delete_cluster_sync_mode_still_uses_busy_retry_loop(
+ self, mock_sleep, mock_conn, mock_delete_cluster
+ ):
+ """Sync mode (deferrable=False) must keep the synchronous busy-retry
loop unchanged."""
+ exception =
boto3.client("redshift").exceptions.InvalidClusterStateFault({}, "test")
+ mock_conn.exceptions.InvalidClusterStateFault = type(exception)
+ mock_delete_cluster.side_effect = [exception, exception, True]
+
+ redshift_operator = RedshiftDeleteClusterOperator(
+ task_id="task_test",
+ cluster_identifier="test_cluster",
+ aws_conn_id="aws_conn_test",
+ deferrable=False,
+ wait_for_completion=False,
+ )
+ redshift_operator.execute(None)
+
+ # Three synchronous attempts, and time.sleep was used between the
retries.
+ assert mock_delete_cluster.call_count == 3
+ assert mock_sleep.called
def test_delete_cluster_execute_complete_success(self):
"""Asserts that logging occurs as expected"""
diff --git
a/providers/amazon/tests/unit/amazon/aws/triggers/test_redshift_cluster.py
b/providers/amazon/tests/unit/amazon/aws/triggers/test_redshift_cluster.py
index a55494ce8ab..42f0b5f1f80 100644
--- a/providers/amazon/tests/unit/amazon/aws/triggers/test_redshift_cluster.py
+++ b/providers/amazon/tests/unit/amazon/aws/triggers/test_redshift_cluster.py
@@ -23,6 +23,7 @@ from unittest import mock
import pytest
from airflow.providers.amazon.aws.triggers.redshift_cluster import (
+ RedshiftClusterSettledTrigger,
RedshiftClusterTrigger,
RedshiftCreateClusterSnapshotTrigger,
RedshiftCreateClusterTrigger,
@@ -152,43 +153,64 @@ WAITER_TRIGGER_PARAMS = [
RedshiftCreateClusterTrigger,
15,
999999,
+ "cluster_available",
id="RedshiftCreateClusterTrigger",
),
pytest.param(
RedshiftPauseClusterTrigger,
15,
999999,
+ "cluster_paused",
id="RedshiftPauseClusterTrigger",
),
pytest.param(
RedshiftCreateClusterSnapshotTrigger,
15,
999999,
+ "snapshot_available",
id="RedshiftCreateClusterSnapshotTrigger",
),
pytest.param(
RedshiftResumeClusterTrigger,
15,
999999,
+ "cluster_resumed",
id="RedshiftResumeClusterTrigger",
),
pytest.param(
RedshiftDeleteClusterTrigger,
30,
30,
+ "cluster_deleted",
id="RedshiftDeleteClusterTrigger",
),
+ pytest.param(
+ RedshiftClusterSettledTrigger,
+ 30,
+ 30,
+ "cluster_deletable",
+ id="RedshiftClusterSettledTrigger",
+ ),
]
class TestRedshiftWaiterTriggers:
- """Tests for the five Redshift triggers that inherit from
``AwsBaseWaiterTrigger``."""
+ """Tests for the Redshift triggers that inherit from
``AwsBaseWaiterTrigger``."""
+
+ @pytest.mark.parametrize(
+ ("trigger_cls", "default_delay", "default_max_attempts",
"waiter_name"),
+ WAITER_TRIGGER_PARAMS,
+ )
+ def test_waiter_name(self, trigger_cls, default_delay,
default_max_attempts, waiter_name):
+ trigger = trigger_cls(cluster_identifier="test_cluster")
+ assert trigger.waiter_name == waiter_name
+ assert trigger.waiter_args == {"ClusterIdentifier": "test_cluster"}
@pytest.mark.parametrize(
- ("trigger_cls", "default_delay", "default_max_attempts"),
+ ("trigger_cls", "default_delay", "default_max_attempts",
"waiter_name"),
WAITER_TRIGGER_PARAMS,
)
- def test_serialization(self, trigger_cls, default_delay,
default_max_attempts):
+ def test_serialization(self, trigger_cls, default_delay,
default_max_attempts, waiter_name):
trigger = trigger_cls(
cluster_identifier="test_cluster",
aws_conn_id="aws_default",
@@ -206,11 +228,11 @@ class TestRedshiftWaiterTriggers:
}
@pytest.mark.parametrize(
- ("trigger_cls", "default_delay", "default_max_attempts"),
+ ("trigger_cls", "default_delay", "default_max_attempts",
"waiter_name"),
WAITER_TRIGGER_PARAMS,
)
def test_serialization_with_verify_and_botocore_config(
- self, trigger_cls, default_delay, default_max_attempts
+ self, trigger_cls, default_delay, default_max_attempts, waiter_name
):
trigger = trigger_cls(
cluster_identifier="test_cluster",
@@ -225,12 +247,12 @@ class TestRedshiftWaiterTriggers:
assert "region_name" not in kwargs
@pytest.mark.parametrize(
- ("trigger_cls", "default_delay", "default_max_attempts"),
+ ("trigger_cls", "default_delay", "default_max_attempts",
"waiter_name"),
WAITER_TRIGGER_PARAMS,
)
@mock.patch("airflow.providers.amazon.aws.triggers.redshift_cluster.RedshiftHook")
def test_hook_propagates_verify_and_botocore_config(
- self, mock_hook_cls, trigger_cls, default_delay, default_max_attempts
+ self, mock_hook_cls, trigger_cls, default_delay, default_max_attempts,
waiter_name
):
trigger = trigger_cls(
cluster_identifier="test_cluster",