This is an automated email from the ASF dual-hosted git repository.
amoghrajesh 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 5d1e2043793 Add durable execution to `RedshiftDataOperator` (#69530)
5d1e2043793 is described below
commit 5d1e2043793f5b7076aaebbe888265a11c8f9ffb
Author: Amogh Desai <[email protected]>
AuthorDate: Mon Jul 13 12:30:56 2026 +0530
Add durable execution to `RedshiftDataOperator` (#69530)
---
.../docs/operators/redshift/redshift_data.rst | 42 +++++
.../amazon/aws/operators/redshift_data.py | 113 ++++++++++-
.../amazon/aws/operators/test_redshift_data.py | 209 ++++++++++++++++++++-
3 files changed, 350 insertions(+), 14 deletions(-)
diff --git a/providers/amazon/docs/operators/redshift/redshift_data.rst
b/providers/amazon/docs/operators/redshift/redshift_data.rst
index 9486e98fb25..7720e304b76 100644
--- a/providers/amazon/docs/operators/redshift/redshift_data.rst
+++ b/providers/amazon/docs/operators/redshift/redshift_data.rst
@@ -66,6 +66,48 @@ the XCom and pass it to the ``session_id`` parameter. This
is useful when you wo
:start-after: [START howto_operator_redshift_data_session_reuse]
:end-before: [END howto_operator_redshift_data_session_reuse]
+Durable execution
+==================
+
+``RedshiftDataOperator`` submits a statement and then polls it to completion
on the worker. By
+default the operator runs in a *durable* mode that makes this crash-safe: the
Redshift statement
+id is persisted to task state before polling begins, so if the worker crashes
or is preempted and
+the task is retried, the operator reconnects to the statement that is already
executing in
+Redshift instead of resubmitting the SQL.
+
+On retry the operator checks the prior statement's state:
+
+* if it is still running, the operator reconnects and continues polling
+* if it already succeeded, the operator returns immediately without
resubmitting
+* if it failed terminally, or its id has expired and is no longer found, the
operator submits the
+ SQL fresh
+
+This protection also applies when ``wait_for_completion=False`` -- even though
that task attempt
+never polls at all, a retry after a successful submission still reconnects
rather than
+resubmitting, since the statement id is persisted immediately after submission
regardless of
+whether the task waits for it to finish.
+
+Durable execution requires Airflow 3.3 or newer, since it relies on the task
state store. On
+earlier Airflow versions the flag is a no-op and the operator always submits
fresh SQL on retry,
+exactly as before. If the task state store is unavailable at runtime, the
operator logs that
+crash recovery is disabled and behaves the same way.
+
+To opt out and always submit fresh SQL on retry, set ``durable=False``:
+
+.. code-block:: python
+
+ statement = RedshiftDataOperator(
+ task_id="redshift_data",
+ database="dev",
+ sql="SELECT * FROM table",
+ cluster_identifier="cluster_identifier",
+ durable=False,
+ )
+
+Durable execution applies to the synchronous path. When ``deferrable=True`` is
set, the Triggerer
+already tracks the statement across the wait, so deferrable mode takes
precedence and ``durable``
+has no effect.
+
Reference
---------
diff --git
a/providers/amazon/src/airflow/providers/amazon/aws/operators/redshift_data.py
b/providers/amazon/src/airflow/providers/amazon/aws/operators/redshift_data.py
index 04dc8c32162..fddc921fbdd 100644
---
a/providers/amazon/src/airflow/providers/amazon/aws/operators/redshift_data.py
+++
b/providers/amazon/src/airflow/providers/amazon/aws/operators/redshift_data.py
@@ -17,25 +17,47 @@
# under the License.
from __future__ import annotations
-from typing import TYPE_CHECKING, Any
+from typing import TYPE_CHECKING, Any, cast
-from airflow.providers.amazon.aws.hooks.redshift_data import RedshiftDataHook
+import botocore.exceptions
+
+from airflow.providers.amazon.aws.hooks.redshift_data import FAILURE_STATES,
FINISHED_STATE, RedshiftDataHook
from airflow.providers.amazon.aws.operators.base_aws import AwsBaseOperator
from airflow.providers.amazon.aws.triggers.redshift_data import
RedshiftDataTrigger
from airflow.providers.amazon.aws.utils import validate_execute_complete_event
from airflow.providers.amazon.aws.utils.mixins import aws_template_fields
from airflow.providers.common.compat.sdk import AirflowException, conf
+try:
+ from airflow.sdk import ResumableJobMixin
+except ImportError:
+
+ class ResumableJobMixin: # type: ignore[no-redef]
+ """Airflow <3.3 stub, task_state_store unavailable, always submits
fresh."""
+
+ external_id_key: str = "redshift_statement_id"
+
+ def __init__(self, *, durable: bool = True, **kwargs: Any) -> None:
+ super().__init__(**kwargs)
+ self.durable = durable
+
+ def execute_resumable(self, context):
+ external_id = self.submit_job(context)
+ self.poll_until_complete(external_id, context)
+ return self.get_job_result(external_id, context)
+
+
if TYPE_CHECKING:
from mypy_boto3_redshift_data.type_defs import (
DescribeStatementResponseTypeDef,
GetStatementResultResponseTypeDef,
)
+ from pydantic import JsonValue
from airflow.sdk import Context
-class RedshiftDataOperator(AwsBaseOperator[RedshiftDataHook]):
+class RedshiftDataOperator(ResumableJobMixin,
AwsBaseOperator[RedshiftDataHook]):
"""
Executes SQL Statements against an Amazon Redshift cluster using Redshift
Data.
@@ -71,9 +93,14 @@ class
RedshiftDataOperator(AwsBaseOperator[RedshiftDataHook]):
https://boto3.amazonaws.com/v1/documentation/api/latest/reference/core/session.html
:param botocore_config: Configuration dictionary (key-values) for botocore
client. See:
https://botocore.amazonaws.com/v1/documentation/api/latest/reference/config.html
+ :param durable: When ``True`` (the default), the Redshift statement id is
persisted to task
+ state before polling begins. A worker crash on retry reconnects to the
existing statement
+ instead of resubmitting the SQL. Set to ``False`` to always submit
fresh SQL on retry.
+ Requires Airflow 3.3+; ignored silently on earlier versions.
"""
aws_hook_class = RedshiftDataHook
+ external_id_key = "redshift_statement_id"
template_fields = aws_template_fields(
"cluster_identifier",
"database",
@@ -133,13 +160,13 @@ class
RedshiftDataOperator(AwsBaseOperator[RedshiftDataHook]):
def execute(self, context: Context) ->
list[GetStatementResultResponseTypeDef] | list[str]:
"""Execute a statement against Amazon Redshift."""
- self.log.info("Executing statement: %s", self.sql)
+ if not self.deferrable:
+ self.execute_resumable(context)
+ return self._sql_results
- # Set wait_for_completion to False so that it waits for the status in
the deferred task.
- wait_for_completion = self.wait_for_completion
- if self.deferrable:
- wait_for_completion = False
+ self.log.info("Executing statement: %s", self.sql)
+ # wait_for_completion=False so that it waits for the status in the
deferred task instead.
query_execution_output = self.hook.execute_query(
database=self.database,
sql=self.sql,
@@ -150,7 +177,7 @@ class
RedshiftDataOperator(AwsBaseOperator[RedshiftDataHook]):
secret_arn=self.secret_arn,
statement_name=self.statement_name,
with_event=self.with_event,
- wait_for_completion=wait_for_completion,
+ wait_for_completion=False,
poll_interval=self.poll_interval,
session_id=self.session_id,
session_keep_alive_seconds=self.session_keep_alive_seconds,
@@ -162,7 +189,7 @@ class
RedshiftDataOperator(AwsBaseOperator[RedshiftDataHook]):
if query_execution_output.session_id:
context["ti"].xcom_push(key="session_id",
value=query_execution_output.session_id)
- if self.deferrable and self.wait_for_completion:
+ if self.wait_for_completion:
is_finished: bool =
self.hook.check_query_is_finished(self.statement_id)
if not is_finished:
self.defer(
@@ -237,3 +264,69 @@ class
RedshiftDataOperator(AwsBaseOperator[RedshiftDataHook]):
self.hook.conn.cancel_statement(Id=self.statement_id)
except Exception as ex:
self.log.error("Unable to cancel query. Exiting. %s", ex)
+
+ def submit_job(self, context: Context) -> str:
+ """Submit the statement for execution and return its statement id."""
+ output = self.hook.execute_query(
+ database=self.database,
+ sql=self.sql,
+ cluster_identifier=self.cluster_identifier,
+ workgroup_name=self.workgroup_name,
+ db_user=self.db_user,
+ parameters=self.parameters,
+ secret_arn=self.secret_arn,
+ statement_name=self.statement_name,
+ with_event=self.with_event,
+ wait_for_completion=False,
+ poll_interval=self.poll_interval,
+ session_id=self.session_id,
+ session_keep_alive_seconds=self.session_keep_alive_seconds,
+ )
+ # Set immediately (before any polling) so on_kill can cancel even if
the worker
+ # dies before poll_until_complete runs.
+ self.statement_id = output.statement_id
+ if output.session_id:
+ context["ti"].xcom_push(key="session_id", value=output.session_id)
+ return self.statement_id
+
+ def get_job_status(self, external_id: JsonValue, context: Context) -> str:
+ """Query the raw statement status; a missing/expired statement
degrades to NOT_FOUND."""
+ statement_id = cast("str", external_id)
+ try:
+ resp = self.hook.conn.describe_statement(Id=statement_id)
+ except botocore.exceptions.ClientError as e:
+ if e.response["Error"]["Code"] in ("ResourceNotFoundException",
"ValidationException"):
+ return "NOT_FOUND"
+ raise
+ return resp["Status"]
+
+ def is_job_active(self, status: str) -> bool:
+ return status not in (FINISHED_STATE, *FAILURE_STATES, "NOT_FOUND")
+
+ def is_job_succeeded(self, status: str) -> bool:
+ return status == FINISHED_STATE
+
+ def poll_until_complete(self, external_id: JsonValue, context: Context) ->
None:
+ statement_id = cast("str", external_id)
+ self.statement_id = statement_id
+ if self.wait_for_completion:
+ self.hook.wait_for_results(statement_id,
poll_interval=self.poll_interval)
+ # Fetch here (not just in get_job_result): on reconnect the mixin
calls only
+ # poll_until_complete, never get_job_result, so execute() reads
self._sql_results
+ # afterward rather than relying on either method's return value.
+ self._sql_results = self.get_sql_results(
+ statement_id=statement_id, return_sql_result=self.return_sql_result
+ )
+ self._result_fetched = True
+
+ def get_job_result(
+ self, external_id: JsonValue, context: Context
+ ) -> list[GetStatementResultResponseTypeDef] | list[str]:
+ statement_id = cast("str", external_id)
+ self.statement_id = statement_id
+ if not getattr(self, "_result_fetched", False):
+ self._sql_results = self.get_sql_results(
+ statement_id=statement_id,
return_sql_result=self.return_sql_result
+ )
+ self._result_fetched = True
+ return self._sql_results
diff --git
a/providers/amazon/tests/unit/amazon/aws/operators/test_redshift_data.py
b/providers/amazon/tests/unit/amazon/aws/operators/test_redshift_data.py
index cb2f08db7e7..6058fdd190b 100644
--- a/providers/amazon/tests/unit/amazon/aws/operators/test_redshift_data.py
+++ b/providers/amazon/tests/unit/amazon/aws/operators/test_redshift_data.py
@@ -19,6 +19,7 @@ from __future__ import annotations
from unittest import mock
+import botocore.exceptions
import pytest
from airflow.providers.amazon.aws.hooks.redshift_data import
QueryExecutionOutput
@@ -26,6 +27,7 @@ from airflow.providers.amazon.aws.operators.redshift_data
import RedshiftDataOpe
from airflow.providers.amazon.aws.triggers.redshift_data import
RedshiftDataTrigger
from airflow.providers.common.compat.sdk import AirflowException, TaskDeferred
+from tests_common.test_utils.version_compat import AIRFLOW_V_3_3_PLUS
from unit.amazon.aws.utils.test_template_fields import validate_template_fields
CONN_ID = "aws_conn_test"
@@ -33,6 +35,7 @@ TASK_ID = "task_id"
SQL = "sql"
DATABASE = "database"
STATEMENT_ID = "statement_id"
+NEW_STATEMENT_ID = "new_statement_id"
SESSION_ID = "session_id"
@@ -145,7 +148,7 @@ class TestRedshiftDataOperator:
statement_name=statement_name,
parameters=parameters,
with_event=False,
- wait_for_completion=True, # Matches above
+ wait_for_completion=False, # submit_job always submits async; the
mixin owns waiting
poll_interval=poll_interval,
session_id=None,
session_keep_alive_seconds=None,
@@ -201,7 +204,7 @@ class TestRedshiftDataOperator:
statement_name=statement_name,
parameters=parameters,
with_event=False,
- wait_for_completion=True,
+ wait_for_completion=False, # submit_job always submits async; the
mixin owns waiting
poll_interval=poll_interval,
session_id=None,
session_keep_alive_seconds=None,
@@ -217,7 +220,6 @@ class TestRedshiftDataOperator:
statement_name = "statement_name"
parameters = [{"name": "id", "value": "1"}]
poll_interval = 5
- wait_for_completion = True
# Like before, return a statement ID and a status
mock_conn.execute_statement.return_value = {"Id": STATEMENT_ID}
@@ -253,7 +255,7 @@ class TestRedshiftDataOperator:
statement_name=statement_name,
parameters=parameters,
with_event=False,
- wait_for_completion=wait_for_completion,
+ wait_for_completion=False, # submit_job always submits async; the
mixin owns waiting
poll_interval=poll_interval,
session_id=None,
session_keep_alive_seconds=123,
@@ -479,3 +481,202 @@ class TestRedshiftDataOperator:
wait_for_completion=False,
)
validate_template_fields(operator)
+
+
[email protected](
+ not AIRFLOW_V_3_3_PLUS, reason="task_state_store (durable execution)
requires Airflow 3.3+"
+)
+class TestRedshiftDataOperatorDurable:
+ @staticmethod
+ def _context(task_store=None):
+ ctx: dict = {"ti": mock.MagicMock(stats_tags={})}
+ if task_store is not None:
+ ctx["task_state_store"] = task_store
+ return ctx
+
+ @staticmethod
+ def _make_operator(**kwargs):
+ return RedshiftDataOperator(
+ task_id=TASK_ID,
+ database=DATABASE,
+ sql=SQL,
+ cluster_identifier="cluster_identifier",
+ **kwargs,
+ )
+
+ @staticmethod
+ def _not_found_error():
+ return botocore.exceptions.ClientError(
+ error_response={"Error": {"Code": "ResourceNotFoundException",
"Message": "not found"}},
+ operation_name="DescribeStatement",
+ )
+
+
@mock.patch("airflow.providers.amazon.aws.hooks.redshift_data.RedshiftDataHook.execute_query")
+
@mock.patch("airflow.providers.amazon.aws.hooks.redshift_data.RedshiftDataHook.conn")
+ def test_persists_statement_id_to_task_state_store_on_fresh_submit(self,
mock_conn, mock_exec_query):
+ operator = self._make_operator()
+ mock_exec_query.return_value =
QueryExecutionOutput(statement_id=STATEMENT_ID, session_id=None)
+ mock_conn.describe_statement.return_value = {"Status": "FINISHED"}
+ task_store = mock.MagicMock(spec_set=["get", "set"])
+ task_store.get.return_value = None
+
+ operator.execute(self._context(task_store))
+
+ mock_exec_query.assert_called_once()
+ task_store.set.assert_called_once_with("redshift_statement_id",
STATEMENT_ID)
+
+
@mock.patch("airflow.providers.amazon.aws.hooks.redshift_data.RedshiftDataHook.execute_query")
+
@mock.patch("airflow.providers.amazon.aws.hooks.redshift_data.RedshiftDataHook.conn")
+ def test_reconnects_to_running_statement_without_resubmitting(self,
mock_conn, mock_exec_query):
+ operator = self._make_operator()
+ # get_job_status sees it still running, wait_for_results then sees it
finish,
+ # get_sql_results makes one more describe_statement call to check for
sub-statements.
+ mock_conn.describe_statement.side_effect = [
+ {"Status": "STARTED"},
+ {"Status": "FINISHED"},
+ {"Status": "FINISHED"},
+ ]
+ task_store = mock.MagicMock(spec_set=["get", "set"])
+ task_store.get.return_value = STATEMENT_ID
+
+ result = operator.execute(self._context(task_store))
+
+ mock_exec_query.assert_not_called()
+ task_store.set.assert_not_called()
+ assert result == [STATEMENT_ID]
+
+
@mock.patch("airflow.providers.amazon.aws.hooks.redshift_data.RedshiftDataHook.execute_query")
+
@mock.patch("airflow.providers.amazon.aws.hooks.redshift_data.RedshiftDataHook.conn")
+ def test_already_succeeded_returns_result_without_polling(self, mock_conn,
mock_exec_query):
+ operator = self._make_operator()
+ mock_conn.describe_statement.return_value = {"Status": "FINISHED"}
+ task_store = mock.MagicMock(spec_set=["get", "set"])
+ task_store.get.return_value = STATEMENT_ID
+
+ result = operator.execute(self._context(task_store))
+
+ mock_exec_query.assert_not_called()
+ assert result == [STATEMENT_ID]
+ # Only get_job_status + get_sql_results should have called
describe_statement -- no poll loop.
+ assert mock_conn.describe_statement.call_count == 2
+
+
@mock.patch("airflow.providers.amazon.aws.hooks.redshift_data.RedshiftDataHook.execute_query")
+
@mock.patch("airflow.providers.amazon.aws.hooks.redshift_data.RedshiftDataHook.conn")
+ def test_resubmits_when_stored_statement_in_terminal_failure(self,
mock_conn, mock_exec_query):
+ operator = self._make_operator()
+ mock_exec_query.return_value =
QueryExecutionOutput(statement_id=NEW_STATEMENT_ID, session_id=None)
+ # get_job_status sees the stored statement failed; after fresh
resubmit, polling succeeds.
+ mock_conn.describe_statement.side_effect = [
+ {"Status": "FAILED"},
+ {"Status": "FINISHED"},
+ {"Status": "FINISHED"},
+ ]
+ task_store = mock.MagicMock(spec_set=["get", "set"])
+ task_store.get.return_value = STATEMENT_ID
+
+ result = operator.execute(self._context(task_store))
+
+ mock_exec_query.assert_called_once()
+ task_store.set.assert_called_once_with("redshift_statement_id",
NEW_STATEMENT_ID)
+ assert result == [NEW_STATEMENT_ID]
+
+
@mock.patch("airflow.providers.amazon.aws.hooks.redshift_data.RedshiftDataHook.execute_query")
+
@mock.patch("airflow.providers.amazon.aws.hooks.redshift_data.RedshiftDataHook.conn")
+ def test_resubmits_when_stored_statement_not_found(self, mock_conn,
mock_exec_query):
+ operator = self._make_operator()
+ mock_exec_query.return_value =
QueryExecutionOutput(statement_id=NEW_STATEMENT_ID, session_id=None)
+ mock_conn.describe_statement.side_effect = [
+ self._not_found_error(),
+ {"Status": "FINISHED"},
+ {"Status": "FINISHED"},
+ ]
+ task_store = mock.MagicMock(spec_set=["get", "set"])
+ task_store.get.return_value = STATEMENT_ID
+
+ result = operator.execute(self._context(task_store))
+
+ mock_exec_query.assert_called_once()
+ task_store.set.assert_called_once_with("redshift_statement_id",
NEW_STATEMENT_ID)
+ assert result == [NEW_STATEMENT_ID]
+
+
@mock.patch("airflow.providers.amazon.aws.hooks.redshift_data.RedshiftDataHook.execute_query")
+
@mock.patch("airflow.providers.amazon.aws.hooks.redshift_data.RedshiftDataHook.conn")
+ def test_durable_false_never_touches_task_state_store(self, mock_conn,
mock_exec_query):
+ operator = self._make_operator(durable=False)
+ mock_exec_query.return_value =
QueryExecutionOutput(statement_id=STATEMENT_ID, session_id=None)
+ mock_conn.describe_statement.return_value = {"Status": "FINISHED"}
+ task_store = mock.MagicMock(spec_set=["get", "set"])
+
+ operator.execute(self._context(task_store))
+
+ mock_exec_query.assert_called_once()
+ task_store.get.assert_not_called()
+ task_store.set.assert_not_called()
+
+
@mock.patch("airflow.providers.amazon.aws.operators.redshift_data.RedshiftDataOperator.defer")
+
@mock.patch("airflow.providers.amazon.aws.hooks.redshift_data.RedshiftDataHook.execute_query")
+
@mock.patch("airflow.providers.amazon.aws.hooks.redshift_data.RedshiftDataHook.conn")
+ def test_deferrable_unaffected_by_durable(self, mock_conn,
mock_exec_query, mock_defer):
+ operator = self._make_operator(deferrable=True)
+ mock_exec_query.return_value =
QueryExecutionOutput(statement_id=STATEMENT_ID, session_id=None)
+ mock_conn.describe_statement.return_value = {"Status": "STARTED"}
+ task_store = mock.MagicMock(spec_set=["get", "set"])
+
+ operator.execute(self._context(task_store))
+
+ assert mock_defer.called
+ task_store.get.assert_not_called()
+ task_store.set.assert_not_called()
+
+
@mock.patch("airflow.providers.amazon.aws.hooks.redshift_data.RedshiftDataHook.execute_query")
+
@mock.patch("airflow.providers.amazon.aws.hooks.redshift_data.RedshiftDataHook.conn")
+ def
test_wait_for_completion_false_still_protects_against_duplicate_submission(
+ self, mock_conn, mock_exec_query
+ ):
+ """A prior attempt already submitted successfully; a retry must not
resubmit even
+ though wait_for_completion=False means this attempt never polls."""
+ operator = self._make_operator(wait_for_completion=False)
+ mock_conn.describe_statement.return_value = {"Status": "FINISHED"}
+ task_store = mock.MagicMock(spec_set=["get", "set"])
+ task_store.get.return_value = STATEMENT_ID
+
+ result = operator.execute(self._context(task_store))
+
+ mock_exec_query.assert_not_called()
+ assert result == [STATEMENT_ID]
+
+
@mock.patch("airflow.providers.amazon.aws.hooks.redshift_data.RedshiftDataHook.conn")
+ def test_get_job_status_returns_raw_status(self, mock_conn):
+ operator = self._make_operator()
+ mock_conn.describe_statement.return_value = {"Status": "STARTED"}
+
+ assert operator.get_job_status(STATEMENT_ID, context={}) == "STARTED"
+
+
@mock.patch("airflow.providers.amazon.aws.hooks.redshift_data.RedshiftDataHook.conn")
+ def test_get_job_status_not_found_on_resource_not_found(self, mock_conn):
+ operator = self._make_operator()
+ mock_conn.describe_statement.side_effect = self._not_found_error()
+
+ assert operator.get_job_status(STATEMENT_ID, context={}) == "NOT_FOUND"
+
+
@mock.patch("airflow.providers.amazon.aws.hooks.redshift_data.RedshiftDataHook.conn")
+ def test_get_job_status_reraises_other_client_errors(self, mock_conn):
+ operator = self._make_operator()
+ mock_conn.describe_statement.side_effect =
botocore.exceptions.ClientError(
+ error_response={"Error": {"Code": "InternalServerException",
"Message": "boom"}},
+ operation_name="DescribeStatement",
+ )
+
+ with pytest.raises(botocore.exceptions.ClientError):
+ operator.get_job_status(STATEMENT_ID, context={})
+
+ def test_is_job_active_and_is_job_succeeded_predicates(self):
+ operator = self._make_operator()
+
+ for status in ("PICKED", "STARTED", "SUBMITTED"):
+ assert operator.is_job_active(status) is True
+ for status in ("FINISHED", "FAILED", "ABORTED", "NOT_FOUND"):
+ assert operator.is_job_active(status) is False
+
+ assert operator.is_job_succeeded("FINISHED") is True
+ assert operator.is_job_succeeded("STARTED") is False