Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions docs/spelling_wordlist.txt
Original file line number Diff line number Diff line change
Expand Up @@ -449,6 +449,7 @@ deferrable
deidentify
DeidentifyTemplate
del
deletable
delim
deliverability
deltalake
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@
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,
Expand Down Expand Up @@ -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. In deferrable mode this bounds the
async wait for a busy cluster to settle before the delete is re-issued; 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(
Expand Down Expand Up @@ -864,15 +867,18 @@ def __init__(
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
# Retry the delete while the cluster is mid-transition (InvalidClusterStateFault) until it
# settles into a deletable state.
self._attempts = 60
self._attempt_interval = 15
self.deferrable = deferrable
self.max_attempts = max_attempts

def execute(self, context: Context):
if self.deferrable:
self._delete_or_defer_until_settled()
return

while self._attempts:
try:
self.hook.delete_cluster(
Expand All @@ -897,36 +903,82 @@ def execute(self, context: Context):
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._delete_or_defer_until_settled()

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This might not work if _retry_delete_when_settled is executed on a different machine/host than execute. If some random values are passed to the construct of RedshiftDeleteClusterOperator, then some values will differ between execute and _retry_delete_when_settled. For example, if a random number is used to define cluster_identifier, then definitely this will fail

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I believe the exact same pattern is used here in EKS:

self.hook.delete_cluster(name=self.cluster_name)

I am going to investigate if this causes a fail but I think if this is an issue in this PR, it's also a latent issue in EKS currently

@seanghaeli seanghaeli Jul 9, 2026

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@vincbeck I just tried the resume step from a different worker than the one that tries the first delete (which fails because the cluster is not in a ready state yet), and it still succeeds.

  1. create redshift cluster
  2. paused the cluster then resumed it (to put it in a mid-transition state)
  3. triggered RedshiftDeleteClusterOperator in deferrable mode
  4. What I observed: worker process pid 738 ran execute while cluster was still mid-transition so it defers.
  5. later, a different worker pid 968 successfully deleted the cluster.

I believe this is fine because the cluster_identifier is serialized in the trigger's init, so it persists in the metastore when the new worker reads it. Here's where it gets serialized:

def _defer_task(
defer: TaskDeferred, ti: RuntimeTaskInstance, log: Logger
) -> tuple[ToSupervisor, TaskInstanceState]:
log.info("Pausing task as DEFERRED. ", dag_id=ti.dag_id, task_id=ti.task_id, run_id=ti.run_id)
classpath, trigger_kwargs = defer.trigger.serialize()

^ where cluster_identifier is included in trigger_kwargs


def execute_complete(self, context: Context, event: dict[str, Any] | None = None) -> None:
validated_event = validate_execute_complete_event(event)

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -343,3 +343,56 @@ async def run(self) -> AsyncIterator[TriggerEvent]:
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_value=None,
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,
)
Original file line number Diff line number Diff line change
Expand Up @@ -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"
}
]
}
}
}
Loading