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 96b820cad5d Add durable execution to SnowflakeSqlApiOperator (#69477)
96b820cad5d is described below

commit 96b820cad5da43387bd1ad404cd5c0c33849eda9
Author: Amogh Desai <[email protected]>
AuthorDate: Tue Jul 14 11:25:58 2026 +0530

    Add durable execution to SnowflakeSqlApiOperator (#69477)
---
 providers/snowflake/docs/operators/snowflake.rst   |  42 +++
 .../providers/snowflake/operators/snowflake.py     | 132 ++++++--
 .../unit/snowflake/operators/test_snowflake.py     | 342 ++++++++++++++++++---
 3 files changed, 459 insertions(+), 57 deletions(-)

diff --git a/providers/snowflake/docs/operators/snowflake.rst 
b/providers/snowflake/docs/operators/snowflake.rst
index b48930c85a2..85c1df3b2ee 100644
--- a/providers/snowflake/docs/operators/snowflake.rst
+++ b/providers/snowflake/docs/operators/snowflake.rst
@@ -154,3 +154,45 @@ An example usage of the SnowflakeSqlApiHook is as follows:
 
   Parameters that can be passed onto the operator will be given priority over 
the parameters already given
   in the Airflow connection metadata (such as ``schema``, ``role``, 
``database`` and so forth).
+
+Durable execution
+^^^^^^^^^^^^^^^^^^
+
+``SnowflakeSqlApiOperator`` submits one or more SQL statements and then polls 
their statement
+handles to completion on the worker. By default the operator runs in a 
*durable* mode that makes
+this crash-safe: the statement handles are persisted to task state store 
before polling begins, so
+if the worker crashes or is preempted and the task is retried, the operator 
reconnects to the
+statements that are already executing in Snowflake instead of resubmitting the 
SQL.
+
+On retry the operator checks the prior statements' state:
+
+* if any handle is still running, the operator reconnects and continues 
polling -- handles that
+  already finished are not re-run, only the ones still in progress are waited 
on
+* if every handle already succeeded, the operator returns immediately without 
resubmitting
+* if any handle failed, or a handle has expired past Snowflake's retention 
window, the operator
+  submits the SQL fresh
+
+Because Snowflake's SQL API has no way to retry or repair a single failed 
statement within a
+multi-statement request, a genuine failure always resubmits the whole request 
batch, matching the
+operator's all-or-nothing submission semantics.
+
+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
+
+  api_operator = SnowflakeSqlApiOperator(
+      task_id="snowflake_sql_api",
+      snowflake_conn_id="snowflake_default",
+      sql="select * from table",
+      statement_count=1,
+      durable=False,
+  )
+
+Durable execution applies to the synchronous path. When ``deferrable=True`` is 
set, the Triggerer
+already tracks the statement handles across the wait, so deferrable mode takes 
precedence and
+``durable`` has no effect.
diff --git 
a/providers/snowflake/src/airflow/providers/snowflake/operators/snowflake.py 
b/providers/snowflake/src/airflow/providers/snowflake/operators/snowflake.py
index b9e74b18589..8e3cda63ef3 100644
--- a/providers/snowflake/src/airflow/providers/snowflake/operators/snowflake.py
+++ b/providers/snowflake/src/airflow/providers/snowflake/operators/snowflake.py
@@ -23,6 +23,8 @@ from datetime import timedelta
 from functools import cached_property
 from typing import TYPE_CHECKING, Any, SupportsAbs, cast
 
+import requests
+
 from airflow.providers.common.compat.sdk import conf
 from airflow.providers.common.sql.operators.sql import (
     SQLCheckOperator,
@@ -33,7 +35,28 @@ from airflow.providers.common.sql.operators.sql import (
 from airflow.providers.snowflake.hooks.snowflake_sql_api import 
SnowflakeSqlApiHook
 from airflow.providers.snowflake.triggers.snowflake_trigger import 
SnowflakeSqlApiTrigger
 
+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 = "snowflake_query_ids"
+
+        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 pydantic import JsonValue
+
     from airflow.providers.common.compat.sdk import Context
 
 
@@ -284,7 +307,7 @@ class 
SnowflakeIntervalCheckOperator(SQLIntervalCheckOperator):
         self.query_ids: list[str] = []
 
 
-class SnowflakeSqlApiOperator(SQLExecuteQueryOperator):
+class SnowflakeSqlApiOperator(ResumableJobMixin, SQLExecuteQueryOperator):
     """
     Implemented Snowflake SQL API Operator to support multiple SQL statements 
sequentially.
 
@@ -357,10 +380,15 @@ class SnowflakeSqlApiOperator(SQLExecuteQueryOperator):
             To set the timeout to the maximum value (604800 seconds), set 
timeout to 0.
     :param deferrable: Run operator in the deferrable mode.
     :param snowflake_api_retry_args: An optional dictionary with arguments 
passed to ``tenacity.Retrying`` & ``tenacity.AsyncRetrying`` classes.
+    :param durable: When ``True`` (the default), the submitted statement 
handles are persisted to
+        task state before polling begins. A worker crash on retry reconnects 
to the existing
+        statements instead of resubmitting the SQL. Set to ``False`` to always 
submit fresh on
+        retry. Requires Airflow 3.3+; ignored silently on earlier versions.
     """
 
     LIFETIME = timedelta(minutes=59)  # The tokens will have a 59 minutes 
lifetime
     RENEWAL_DELTA = timedelta(minutes=54)  # Tokens will be renewed after 54 
minutes
+    external_id_key = "snowflake_query_ids"
 
     template_fields: Sequence[str] = tuple(
         set(SQLExecuteQueryOperator.template_fields) | {"snowflake_conn_id"}
@@ -428,6 +456,9 @@ class SnowflakeSqlApiOperator(SQLExecuteQueryOperator):
 
         By deferring the SnowflakeSqlApiTrigger class passed along with query 
ids.
         """
+        if not self.deferrable:
+            return self.execute_resumable(context)
+
         self.log.info("Executing: %s", self.sql)
         self.query_ids = self._hook.execute_query(
             self.sql, statement_count=self.statement_count, 
bindings=self.bindings, timeout=self.timeout
@@ -452,27 +483,17 @@ class SnowflakeSqlApiOperator(SQLExecuteQueryOperator):
             self.log.info("%s completed successfully.", self.task_id)
             return
 
-        if self.deferrable:
-            self.defer(
-                timeout=self.execution_timeout,
-                trigger=SnowflakeSqlApiTrigger(
-                    poll_interval=self.poll_interval,
-                    query_ids=self.query_ids,
-                    snowflake_conn_id=self.snowflake_conn_id,
-                    token_life_time=self.token_life_time,
-                    token_renewal_delta=self.token_renewal_delta,
-                ),
-                method_name="execute_complete",
-            )
-        else:
-            while True:
-                statement_status = self.poll_on_queries()
-                if statement_status["error"]:
-                    raise RuntimeError(str(statement_status["error"]))
-                if not statement_status["running"]:
-                    break
-
-            self._hook.check_query_output(self.query_ids)
+        self.defer(
+            timeout=self.execution_timeout,
+            trigger=SnowflakeSqlApiTrigger(
+                poll_interval=self.poll_interval,
+                query_ids=self.query_ids,
+                snowflake_conn_id=self.snowflake_conn_id,
+                token_life_time=self.token_life_time,
+                token_renewal_delta=self.token_renewal_delta,
+            ),
+            method_name="execute_complete",
+        )
 
     def poll_on_queries(self):
         """Poll on requested queries."""
@@ -507,6 +528,73 @@ class SnowflakeSqlApiOperator(SQLExecuteQueryOperator):
             "running": statement_running_status,
         }
 
+    def submit_job(self, context: Context) -> JsonValue:
+        """Submit the SQL for execution and return the resulting statement 
handles."""
+        self.log.info("Executing: %s", self.sql)
+        self.query_ids = self._hook.execute_query(
+            self.sql, statement_count=self.statement_count, 
bindings=self.bindings, timeout=self.timeout
+        )
+        self.log.info("List of query ids %s", self.query_ids)
+        return cast("JsonValue", self.query_ids)
+
+    def get_job_status(self, external_id: JsonValue, context: Context) -> str:
+        """Aggregate the status of every handle into a single verdict for the 
mixin."""
+        statuses = []
+        for query_id in cast("list[str]", external_id):
+            try:
+                
statuses.append(self._hook.get_sql_api_query_status(query_id)["status"])
+            except requests.exceptions.HTTPError as e:
+                if e.response is not None and e.response.status_code == 404:
+                    return "not_found"
+                raise
+        if "error" in statuses:
+            return "error"
+        if "running" in statuses:
+            return "running"
+        return "success"
+
+    def is_job_active(self, status: str) -> bool:
+        return status == "running"
+
+    def is_job_succeeded(self, status: str) -> bool:
+        return status == "success"
+
+    def poll_until_complete(self, external_id: JsonValue, context: Context) -> 
None:
+        self.query_ids = cast("list[str]", external_id)
+        # On reconnect, execute_query (the only thing that normally populates 
this) never ran
+        # on this hook instance -- sync it so OpenLineage's 
get_openlineage_database_specific_lineage,
+        # which reads hook.query_ids (not the operator's), doesn't silently 
produce no lineage.
+        self._hook.query_ids = self.query_ids
+        # Push before polling, not after, so the handles are recorded even if 
a statement
+        # errors below.
+        if self.do_xcom_push and context is not None:
+            context["ti"].xcom_push(key="query_ids", value=self.query_ids)
+        while True:
+            statement_status = self.poll_on_queries()
+            if statement_status["error"]:
+                raise RuntimeError(str(statement_status["error"]))
+            if not statement_status["running"]:
+                break
+        # On reconnect, the mixin calls poll_until_complete alone -- 
get_job_result is never
+        # invoked in that case -- so the output must be fetched here too, not 
left to
+        # get_job_result. Fresh submit calls both; the flag stops 
get_job_result from
+        # fetching (and pushing xcoms) a second time.
+        self._hook.check_query_output(self.query_ids)
+        self._poll_until_complete_ran = True
+
+    def get_job_result(self, external_id: JsonValue, context: Context) -> None:
+        self.query_ids = cast("list[str]", external_id)
+        # Same reconnect-hook gap as poll_until_complete -- see the comment 
there. This path
+        # hits it too, since the already-succeeded case never calls 
poll_until_complete either.
+        self._hook.query_ids = self.query_ids
+        if getattr(self, "_poll_until_complete_ran", False):
+            return
+        # The already-succeeded retry path skips submit_job and 
poll_until_complete entirely,
+        # so push the query_ids xcom and fetch output here for parity with the 
normal path.
+        if self.do_xcom_push and context is not None:
+            context["ti"].xcom_push(key="query_ids", value=self.query_ids)
+        self._hook.check_query_output(self.query_ids)
+
     def execute_complete(self, context: Context, event: dict[str, str | 
list[str]] | None = None) -> None:
         """
         Execute callback when the trigger fires; returns immediately.
diff --git 
a/providers/snowflake/tests/unit/snowflake/operators/test_snowflake.py 
b/providers/snowflake/tests/unit/snowflake/operators/test_snowflake.py
index b1d6b418839..d37f420f2a6 100644
--- a/providers/snowflake/tests/unit/snowflake/operators/test_snowflake.py
+++ b/providers/snowflake/tests/unit/snowflake/operators/test_snowflake.py
@@ -18,10 +18,11 @@
 from __future__ import annotations
 
 from unittest import mock
-from unittest.mock import call
+from unittest.mock import MagicMock, call
 
 import pendulum
 import pytest
+import requests
 
 from airflow.models import Connection
 from airflow.models.dag import DAG
@@ -41,7 +42,7 @@ from airflow.utils.types import DagRunType
 from tests_common.test_utils.dag import sync_dag_to_db
 from tests_common.test_utils.db import clear_db_dag_bundles, clear_db_dags, 
clear_db_runs
 from tests_common.test_utils.taskinstance import create_task_instance
-from tests_common.test_utils.version_compat import AIRFLOW_V_3_0_PLUS, timezone
+from tests_common.test_utils.version_compat import AIRFLOW_V_3_0_PLUS, 
AIRFLOW_V_3_3_PLUS, timezone
 
 DEFAULT_DATE = timezone.datetime(2015, 1, 1)
 DEFAULT_DATE_ISO = DEFAULT_DATE.isoformat()
@@ -272,6 +273,30 @@ def create_context(task, dag=None):
     }
 
 
[email protected]
+def mock_execute_query():
+    with mock.patch(
+        
"airflow.providers.snowflake.operators.snowflake.SnowflakeSqlApiHook.execute_query"
+    ) as execute_query:
+        yield execute_query
+
+
[email protected]
+def mock_get_sql_api_query_status():
+    with mock.patch(
+        
"airflow.providers.snowflake.operators.snowflake.SnowflakeSqlApiHook.get_sql_api_query_status"
+    ) as get_sql_api_query_status:
+        yield get_sql_api_query_status
+
+
[email protected]
+def mock_check_query_output():
+    with mock.patch(
+        
"airflow.providers.snowflake.operators.snowflake.SnowflakeSqlApiHook.check_query_output"
+    ) as check_query_output:
+        yield check_query_output
+
+
 @pytest.mark.db_test
 class TestSnowflakeSqlApiOperator:
     @pytest.fixture(autouse=True)
@@ -288,27 +313,6 @@ class TestSnowflakeSqlApiOperator:
         if AIRFLOW_V_3_0_PLUS:
             clear_db_dag_bundles()
 
-    @pytest.fixture
-    def mock_execute_query(self):
-        with mock.patch(
-            
"airflow.providers.snowflake.operators.snowflake.SnowflakeSqlApiHook.execute_query"
-        ) as execute_query:
-            yield execute_query
-
-    @pytest.fixture
-    def mock_get_sql_api_query_status(self):
-        with mock.patch(
-            
"airflow.providers.snowflake.operators.snowflake.SnowflakeSqlApiHook.get_sql_api_query_status"
-        ) as get_sql_api_query_status:
-            yield get_sql_api_query_status
-
-    @pytest.fixture
-    def mock_check_query_output(self):
-        with mock.patch(
-            
"airflow.providers.snowflake.operators.snowflake.SnowflakeSqlApiHook.check_query_output"
-        ) as check_query_output:
-            yield check_query_output
-
     def test_snowflake_sql_api_to_succeed_when_no_query_fails(
         self, mock_execute_query, mock_get_sql_api_query_status, 
mock_check_query_output
     ):
@@ -320,11 +324,31 @@ class TestSnowflakeSqlApiOperator:
             sql=SQL_MULTIPLE_STMTS,
             statement_count=4,
             do_xcom_push=False,
+            durable=False,
         )
         mock_execute_query.return_value = ["uuid1", "uuid2"]
         mock_get_sql_api_query_status.side_effect = [{"status": "success"}, 
{"status": "success"}]
         operator.execute(context=None)
 
+    def 
test_snowflake_sql_api_durable_true_submits_fresh_missing_task_state_store(
+        self, mock_execute_query, mock_get_sql_api_query_status, 
mock_check_query_output
+    ):
+        """Default durable=True with no task_state_store in context still 
submits and succeeds."""
+        operator = SnowflakeSqlApiOperator(
+            task_id=TASK_ID,
+            snowflake_conn_id="snowflake_default",
+            sql=SQL_MULTIPLE_STMTS,
+            statement_count=4,
+            do_xcom_push=False,
+        )
+        mock_execute_query.return_value = ["uuid1", "uuid2"]
+        mock_get_sql_api_query_status.side_effect = [{"status": "success"}, 
{"status": "success"}]
+
+        operator.execute(context={})
+
+        mock_execute_query.assert_called_once()
+        mock_check_query_output.assert_called_once_with(["uuid1", "uuid2"])
+
     def test_snowflake_sql_api_to_fails_when_one_query_fails(
         self, mock_execute_query, mock_get_sql_api_query_status
     ):
@@ -336,6 +360,7 @@ class TestSnowflakeSqlApiOperator:
             sql=SQL_MULTIPLE_STMTS,
             statement_count=4,
             do_xcom_push=False,
+            durable=False,
         )
         mock_execute_query.return_value = ["uuid1", "uuid2"]
         mock_get_sql_api_query_status.side_effect = [{"status": "error"}, 
{"status": "success"}]
@@ -359,7 +384,9 @@ class TestSnowflakeSqlApiOperator:
         with pytest.raises(RuntimeError, match="Failed to get status for query 
uuid1"):
             operator.poll_on_queries()
 
-    def test_poll_on_queries_no_sleep_when_all_resolved(self, 
mock_get_sql_api_query_status):
+    def test_poll_on_queries_no_sleep_when_all_resolved(
+        self, mock_execute_query, mock_get_sql_api_query_status
+    ):
         operator = SnowflakeSqlApiOperator(
             task_id=TASK_ID,
             snowflake_conn_id="snowflake_default",
@@ -378,7 +405,7 @@ class TestSnowflakeSqlApiOperator:
         assert result["error"] == {"uuid2": {"status": "error"}}
         assert result["running"] == {}
 
-    def test_poll_on_queries_sleeps_once_per_cycle(self, 
mock_get_sql_api_query_status):
+    def test_poll_on_queries_sleeps_once_per_cycle(self, mock_execute_query, 
mock_get_sql_api_query_status):
         """One handle is still running, so the cycle sleeps -- but only once, 
not per handle."""
         operator = SnowflakeSqlApiOperator(
             task_id=TASK_ID,
@@ -434,6 +461,7 @@ class TestSnowflakeSqlApiOperator:
         self,
         mock_execute_query,
         mock_get_sql_api_query_status,
+        mock_check_query_output,
     ):
         """
         Tests that query IDs returned by the Snowflake SQL API are pushed to 
XCom
@@ -446,6 +474,7 @@ class TestSnowflakeSqlApiOperator:
             statement_count=4,
             do_xcom_push=True,
             deferrable=False,
+            durable=False,
         )
 
         mock_execute_query.return_value = ["uuid1"]
@@ -615,27 +644,29 @@ class TestSnowflakeSqlApiOperator:
             statement_count=4,
             do_xcom_push=False,
             deferrable=False,
+            durable=False,
         )
 
         mock_execute_query.return_value = ["uuid1"]
 
         mock_get_sql_api_query_status.side_effect = [
-            # Initial get_sql_api_query_status check
-            {"status": "running"},
             # 1st poll_on_queries check (poll_interval: 5s) -- still running, 
sleeps
             {"status": "running"},
             # 2nd poll_on_queries check (poll_interval: 5s) -- still running, 
sleeps
             {"status": "running"},
-            # 3rd poll_on_queries check -- resolves to success, no sleep needed
+            # 3rd poll_on_queries check (poll_interval: 5s) -- still running, 
sleeps
+            {"status": "running"},
+            # 4th poll_on_queries check -- resolves to success, no sleep needed
             {"status": "success"},
         ]
 
         with mock.patch("time.sleep") as mock_sleep:
             operator.execute(context=None)
             mock_check_query_output.assert_called_once_with(["uuid1"])
-            # Only 2 sleeps: the cycle that resolves the last running query 
returns
-            # immediately instead of sleeping once more before reporting 
success.
-            assert mock_sleep.call_count == 2
+            # 3 sleeps: durable=False routes straight into poll_until_complete 
with no
+            # separate pre-check, so every "running" cycle (including the 
first) sleeps;
+            # only the cycle that resolves to success skips it.
+            assert mock_sleep.call_count == 3
 
     def test_snowflake_sql_api_execute_operator_polling_failed(
         self, mock_execute_query, mock_get_sql_api_query_status, 
mock_check_query_output
@@ -651,21 +682,44 @@ class TestSnowflakeSqlApiOperator:
             statement_count=4,
             do_xcom_push=False,
             deferrable=False,
+            durable=False,
         )
 
         mock_execute_query.return_value = ["uuid1"]
 
         mock_get_sql_api_query_status.side_effect = [
-            # Initial get_sql_api_query_status check
+            # 1st poll_on_queries check -- still running, sleeps
             {"status": "running"},
-            # 1st poll_on_queries check
+            # 2nd poll_on_queries check -- resolves to error, raises 
immediately
             {"status": "error"},
         ]
 
-        with pytest.raises(RuntimeError):
-            operator.execute(context=None)
+        with mock.patch("time.sleep") as mock_sleep:
+            with pytest.raises(RuntimeError):
+                operator.execute(context=None)
+            # 1 sleep: the first cycle finds "running" and sleeps before the 
second
+            # cycle finds "error" and raises without sleeping again.
+            assert mock_sleep.call_count == 1
         mock_check_query_output.assert_not_called()
 
+    def test_poll_until_complete_pushes_query_ids_to_xcom_even_on_failure(
+        self, mock_get_sql_api_query_status
+    ):
+        operator = SnowflakeSqlApiOperator(
+            task_id=TASK_ID,
+            snowflake_conn_id=CONN_ID,
+            sql=SQL_MULTIPLE_STMTS,
+            statement_count=4,
+            do_xcom_push=True,
+        )
+        mock_get_sql_api_query_status.side_effect = [{"status": "error"}]
+        context = create_context(operator)
+
+        with pytest.raises(RuntimeError):
+            operator.poll_until_complete(["uuid1"], context)
+
+        context["ti"].xcom_push.assert_called_once_with(key="query_ids", 
value=["uuid1"])
+
     
@mock.patch("airflow.providers.snowflake.hooks.snowflake_sql_api.SnowflakeSqlApiHook.cancel_queries")
     def test_snowflake_sql_api_on_kill_cancels_queries(self, 
mock_cancel_queries):
         """Test that on_kill cancels running queries."""
@@ -695,3 +749,221 @@ class TestSnowflakeSqlApiOperator:
         operator.on_kill()
 
         mock_cancel_queries.assert_not_called()
+
+
[email protected](
+    not AIRFLOW_V_3_3_PLUS, reason="task_state_store (durable execution) 
requires Airflow 3.3+"
+)
+class TestSnowflakeSqlApiOperatorDurable:
+    @staticmethod
+    def _context(task_store=None):
+        ctx: dict = {"ti": MagicMock(stats_tags={})}
+        if task_store is not None:
+            ctx["task_state_store"] = task_store
+        return ctx
+
+    @staticmethod
+    def _make_operator(**kwargs):
+        return SnowflakeSqlApiOperator(
+            task_id=TASK_ID,
+            snowflake_conn_id="snowflake_default",
+            sql=SQL_MULTIPLE_STMTS,
+            statement_count=4,
+            do_xcom_push=False,
+            **kwargs,
+        )
+
+    def test_persists_query_ids_to_task_state_store_on_fresh_submit(
+        self, mock_execute_query, mock_get_sql_api_query_status, 
mock_check_query_output
+    ):
+        operator = self._make_operator()
+        mock_execute_query.return_value = ["uuid1"]
+        mock_get_sql_api_query_status.return_value = {"status": "success"}
+        task_store = MagicMock(spec_set=["get", "set"])
+        task_store.get.return_value = None
+
+        operator.execute(self._context(task_store))
+
+        mock_execute_query.assert_called_once()
+        task_store.set.assert_called_once_with("snowflake_query_ids", 
["uuid1"])
+
+    def test_reconnects_to_running_query_without_resubmitting(
+        self, mock_execute_query, mock_get_sql_api_query_status, 
mock_check_query_output
+    ):
+        operator = self._make_operator()
+        # get_job_status sees it still running, then poll_on_queries sees it 
finish.
+        mock_get_sql_api_query_status.side_effect = [{"status": "running"}, 
{"status": "success"}]
+        task_store = MagicMock(spec_set=["get", "set"])
+        task_store.get.return_value = ["uuid1"]
+
+        operator.execute(self._context(task_store))
+
+        mock_execute_query.assert_not_called()
+        task_store.set.assert_not_called()
+        # execute_query never ran on this hook instance, so nothing else would 
populate this --
+        # OpenLineage's get_openlineage_database_specific_lineage reads 
hook.query_ids directly.
+        assert operator._hook.query_ids == ["uuid1"]
+
+    def test_partial_progress_reconnect_waits_only_on_running_handle(
+        self, mock_execute_query, mock_get_sql_api_query_status, 
mock_check_query_output
+    ):
+        operator = self._make_operator()
+        mock_get_sql_api_query_status.side_effect = [
+            {"status": "success"},  # get_job_status check: uuid1 already done
+            {"status": "running"},  # get_job_status check: uuid2 -> aggregate 
"running" -> reconnect
+            {"status": "success"},  # poll cycle 1: uuid1 re-confirmed
+            {"status": "running"},  # poll cycle 1: uuid2 still running -> 
sleep
+            {"status": "success"},  # poll cycle 2: uuid1 re-confirmed
+            {"status": "success"},  # poll cycle 2: uuid2 finishes
+        ]
+        task_store = MagicMock(spec_set=["get", "set"])
+        task_store.get.return_value = ["uuid1", "uuid2"]
+
+        with mock.patch("time.sleep"):
+            operator.execute(self._context(task_store))
+
+        mock_execute_query.assert_not_called()
+        task_store.set.assert_not_called()
+        mock_check_query_output.assert_called_once_with(["uuid1", "uuid2"])
+        assert operator._hook.query_ids == ["uuid1", "uuid2"]
+
+    def test_already_succeeded_returns_result_without_polling(
+        self, mock_execute_query, mock_get_sql_api_query_status, 
mock_check_query_output
+    ):
+        operator = self._make_operator()
+        mock_get_sql_api_query_status.return_value = {"status": "success"}
+        task_store = MagicMock(spec_set=["get", "set"])
+        task_store.get.return_value = ["uuid1"]
+
+        operator.execute(self._context(task_store))
+
+        mock_execute_query.assert_not_called()
+        mock_check_query_output.assert_called_once_with(["uuid1"])
+        # Only the get_job_status check ran; poll_on_queries must never have 
been entered.
+        assert mock_get_sql_api_query_status.call_count == 1
+        assert operator._hook.query_ids == ["uuid1"]
+
+    def test_already_succeeded_pushes_query_ids_to_xcom(
+        self, mock_execute_query, mock_get_sql_api_query_status, 
mock_check_query_output
+    ):
+        operator = SnowflakeSqlApiOperator(
+            task_id=TASK_ID,
+            snowflake_conn_id="snowflake_default",
+            sql=SQL_MULTIPLE_STMTS,
+            statement_count=4,
+            do_xcom_push=True,
+        )
+        mock_get_sql_api_query_status.return_value = {"status": "success"}
+        task_store = MagicMock(spec_set=["get", "set"])
+        task_store.get.return_value = ["uuid1"]
+        context = self._context(task_store)
+
+        operator.execute(context)
+
+        mock_execute_query.assert_not_called()
+        context["ti"].xcom_push.assert_called_once_with(key="query_ids", 
value=["uuid1"])
+
+    def test_resubmits_when_stored_query_in_terminal_error(
+        self, mock_execute_query, mock_get_sql_api_query_status, 
mock_check_query_output
+    ):
+        operator = self._make_operator()
+        mock_execute_query.return_value = ["uuid2"]
+        # get_job_status sees the stored handle failed; after fresh resubmit, 
polling succeeds.
+        mock_get_sql_api_query_status.side_effect = [{"status": "error"}, 
{"status": "success"}]
+        task_store = MagicMock(spec_set=["get", "set"])
+        task_store.get.return_value = ["uuid1"]
+
+        operator.execute(self._context(task_store))
+
+        mock_execute_query.assert_called_once()
+        task_store.set.assert_called_once_with("snowflake_query_ids", 
["uuid2"])
+
+    def test_resubmits_when_stored_query_not_found(
+        self, mock_execute_query, mock_get_sql_api_query_status, 
mock_check_query_output
+    ):
+        operator = self._make_operator()
+        mock_execute_query.return_value = ["uuid2"]
+        not_found_response = MagicMock()
+        not_found_response.status_code = 404
+        # get_job_status sees the stored handle expired (404); after fresh 
resubmit, polling succeeds.
+        mock_get_sql_api_query_status.side_effect = [
+            requests.exceptions.HTTPError(response=not_found_response),
+            {"status": "success"},
+        ]
+        task_store = MagicMock(spec_set=["get", "set"])
+        task_store.get.return_value = ["uuid1"]
+
+        operator.execute(self._context(task_store))
+
+        mock_execute_query.assert_called_once()
+        task_store.set.assert_called_once_with("snowflake_query_ids", 
["uuid2"])
+
+    def test_durable_false_never_touches_task_state_store(
+        self, mock_execute_query, mock_get_sql_api_query_status, 
mock_check_query_output
+    ):
+        operator = self._make_operator(durable=False)
+        mock_execute_query.return_value = ["uuid1"]
+        mock_get_sql_api_query_status.return_value = {"status": "success"}
+        task_store = MagicMock(spec_set=["get", "set"])
+
+        operator.execute(self._context(task_store))
+
+        mock_execute_query.assert_called_once()
+        task_store.get.assert_not_called()
+        task_store.set.assert_not_called()
+
+    def test_deferrable_unaffected_by_durable(self, mock_execute_query, 
mock_get_sql_api_query_status):
+        operator = self._make_operator(deferrable=True)
+        mock_execute_query.return_value = ["uuid1"]
+        mock_get_sql_api_query_status.return_value = {"status": "running"}
+        task_store = MagicMock(spec_set=["get", "set"])
+
+        with mock.patch.object(SnowflakeSqlApiOperator, "defer") as mock_defer:
+            operator.execute(self._context(task_store))
+
+        assert mock_defer.called
+        task_store.get.assert_not_called()
+        task_store.set.assert_not_called()
+
+    def test_get_job_status_error_takes_priority_over_running(self, 
mock_get_sql_api_query_status):
+        operator = self._make_operator()
+        mock_get_sql_api_query_status.side_effect = [{"status": "running"}, 
{"status": "error"}]
+
+        assert operator.get_job_status(["uuid1", "uuid2"], context={}) == 
"error"
+
+    def test_get_job_status_running_when_none_error(self, 
mock_get_sql_api_query_status):
+        operator = self._make_operator()
+        mock_get_sql_api_query_status.side_effect = [{"status": "success"}, 
{"status": "running"}]
+
+        assert operator.get_job_status(["uuid1", "uuid2"], context={}) == 
"running"
+
+    def test_get_job_status_success_when_all_succeed(self, 
mock_get_sql_api_query_status):
+        operator = self._make_operator()
+        mock_get_sql_api_query_status.return_value = {"status": "success"}
+
+        assert operator.get_job_status(["uuid1", "uuid2"], context={}) == 
"success"
+
+    def test_get_job_status_not_found_on_404(self, 
mock_get_sql_api_query_status):
+        operator = self._make_operator()
+        response = MagicMock()
+        response.status_code = 404
+        mock_get_sql_api_query_status.side_effect = 
requests.exceptions.HTTPError(response=response)
+
+        assert operator.get_job_status(["uuid1"], context={}) == "not_found"
+
+    def test_get_job_status_reraises_non_404_http_error(self, 
mock_get_sql_api_query_status):
+        operator = self._make_operator()
+        response = MagicMock()
+        response.status_code = 500
+        mock_get_sql_api_query_status.side_effect = 
requests.exceptions.HTTPError(response=response)
+
+        with pytest.raises(requests.exceptions.HTTPError):
+            operator.get_job_status(["uuid1"], context={})
+
+    def test_is_job_active_and_is_job_succeeded_predicates(self):
+        operator = self._make_operator()
+
+        assert operator.is_job_active("running") is True
+        assert operator.is_job_active("success") is False
+        assert operator.is_job_succeeded("success") is True
+        assert operator.is_job_succeeded("running") is False

Reply via email to