This is an automated email from the ASF dual-hosted git repository.

potiuk pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/airflow.git


The following commit(s) were added to refs/heads/main by this push:
     new eb0cf61f7a0 Add deferrable mode to SnowparkContainerJobOperator 
(#70103)
eb0cf61f7a0 is described below

commit eb0cf61f7a0e8238abc359f7993449a116c1fab5
Author: Justin Pakzad <[email protected]>
AuthorDate: Tue Sep 8 06:34:55 2026 -0400

    Add deferrable mode to SnowparkContainerJobOperator (#70103)
    
    * Add deferrable mode to SnowparkContainerJobOperator
    
    Deferrable mode releases the worker slot while a container job runs, polling
    status on the triggerer instead of blocking a worker for the job's full
    duration.
    
    The poll is now bounded by a timeout so a job that never reaches a terminal
    state no longer blocks indefinitely. The service is dropped on completion,
    timeout, or kill to avoid leaving a still-billing resource, and the drop is
    best-effort so a cleanup failure does not fail an otherwise successful job.
    
    * Update 
providers/snowflake/tests/unit/snowflake/operators/test_snowpark_containers.py
    
    ---------
    
    Co-authored-by: Jarek Potiuk <[email protected]>
---
 providers/snowflake/docs/changelog.rst             |   5 +
 .../docs/operators/snowpark_containers.rst         |   8 +
 providers/snowflake/provider.yaml                  |   1 +
 .../providers/snowflake/get_provider_info.py       |   5 +-
 .../snowflake/operators/snowpark_containers.py     | 149 ++++++++++-----
 .../snowflake/triggers/snowpark_containers.py      | 166 +++++++++++++++++
 .../snowflake/utils/snowpark_containers.py         |  52 ++++++
 .../operators/test_snowpark_containers.py          | 153 +++++++++++++++
 .../snowflake/triggers/test_snowpark_containers.py | 205 +++++++++++++++++++++
 .../snowflake/utils/test_snowpark_containers.py    |  28 +++
 10 files changed, 723 insertions(+), 49 deletions(-)

diff --git a/providers/snowflake/docs/changelog.rst 
b/providers/snowflake/docs/changelog.rst
index 1cf05253c33..938e6395a25 100644
--- a/providers/snowflake/docs/changelog.rst
+++ b/providers/snowflake/docs/changelog.rst
@@ -27,6 +27,11 @@
 Changelog
 ---------
 
+.. warning::
+  ``SnowparkContainerJobOperator`` now applies a default ``timeout`` of 24 
hours where it
+  previously polled indefinitely, so a task running longer than a day now 
fails. Increase
+  ``timeout`` to allow more time.
+
 6.16.1
 ......
 
diff --git a/providers/snowflake/docs/operators/snowpark_containers.rst 
b/providers/snowflake/docs/operators/snowpark_containers.rst
index 8677d16207c..3d475350db1 100644
--- a/providers/snowflake/docs/operators/snowpark_containers.rst
+++ b/providers/snowflake/docs/operators/snowpark_containers.rst
@@ -31,6 +31,14 @@ SQL command. It submits the job asynchronously, optionally 
polls until the job
 reaches a terminal state, retrieves container logs, and can drop the service on
 completion.
 
+You can also run this operator in deferrable mode by setting ``deferrable`` 
param to ``True``.
+This will ensure that the task is deferred from the Airflow worker slot and 
polling for the job status happens on the trigger.
+
+.. note::
+
+  In deferrable mode, clearing or marking a deferred task as failed drops the 
job service
+  only on Airflow 3.3 or later. On earlier versions the service is left 
running and billing.
+
 Prerequisite Tasks
 ^^^^^^^^^^^^^^^^^^
 
diff --git a/providers/snowflake/provider.yaml 
b/providers/snowflake/provider.yaml
index 415ef25a1b3..31ec68afaf3 100644
--- a/providers/snowflake/provider.yaml
+++ b/providers/snowflake/provider.yaml
@@ -300,6 +300,7 @@ triggers:
   - integration-name: Snowflake
     python-modules:
       - airflow.providers.snowflake.triggers.snowflake_trigger
+      - airflow.providers.snowflake.triggers.snowpark_containers
 
 config:
   snowflake:
diff --git 
a/providers/snowflake/src/airflow/providers/snowflake/get_provider_info.py 
b/providers/snowflake/src/airflow/providers/snowflake/get_provider_info.py
index 1b9456e0298..ea14db2fba0 100644
--- a/providers/snowflake/src/airflow/providers/snowflake/get_provider_info.py
+++ b/providers/snowflake/src/airflow/providers/snowflake/get_provider_info.py
@@ -167,7 +167,10 @@ def get_provider_info():
         "triggers": [
             {
                 "integration-name": "Snowflake",
-                "python-modules": 
["airflow.providers.snowflake.triggers.snowflake_trigger"],
+                "python-modules": [
+                    "airflow.providers.snowflake.triggers.snowflake_trigger",
+                    "airflow.providers.snowflake.triggers.snowpark_containers",
+                ],
             }
         ],
         "config": {
diff --git 
a/providers/snowflake/src/airflow/providers/snowflake/operators/snowpark_containers.py
 
b/providers/snowflake/src/airflow/providers/snowflake/operators/snowpark_containers.py
index ac8e536b920..7ae9175b494 100644
--- 
a/providers/snowflake/src/airflow/providers/snowflake/operators/snowpark_containers.py
+++ 
b/providers/snowflake/src/airflow/providers/snowflake/operators/snowpark_containers.py
@@ -19,51 +19,25 @@ from __future__ import annotations
 
 import time
 from collections.abc import Sequence
-from enum import Enum
+from datetime import timedelta
 from functools import cached_property
 from typing import TYPE_CHECKING, Any
 
+from airflow.providers.common.compat.sdk import conf
 from airflow.providers.common.compat.standard.operators import BaseOperator
 from airflow.providers.common.sql.hooks.handlers import fetch_one_handler
 from airflow.providers.snowflake.hooks.snowflake import SnowflakeHook
+from airflow.providers.snowflake.triggers.snowpark_containers import 
SnowparkContainerJobTrigger
+from airflow.providers.snowflake.utils.snowpark_containers import (
+    NON_TERMINAL_STATUSES,
+    TERMINAL_STATUSES,
+    SnowparkContainerJobStatus,
+)
 
 if TYPE_CHECKING:
     from airflow.providers.common.compat.sdk import Context
 
 
-class SnowparkContainerJobStatus(str, Enum):
-    """Statuses of a Snowpark Container Services."""
-
-    PENDING = "PENDING"
-    RUNNING = "RUNNING"
-    CANCELLING = "CANCELLING"
-    SUSPENDING = "SUSPENDING"
-    DELETING = "DELETING"
-    DONE = "DONE"
-    FAILED = "FAILED"
-    CANCELLED = "CANCELLED"
-    INTERNAL_ERROR = "INTERNAL_ERROR"
-
-
-TERMINAL_STATUSES: frozenset[SnowparkContainerJobStatus] = frozenset(
-    {
-        SnowparkContainerJobStatus.DONE,
-        SnowparkContainerJobStatus.FAILED,
-        SnowparkContainerJobStatus.CANCELLED,
-        SnowparkContainerJobStatus.INTERNAL_ERROR,
-    }
-)
-NON_TERMINAL_STATUSES: frozenset[SnowparkContainerJobStatus] = frozenset(
-    {
-        SnowparkContainerJobStatus.PENDING,
-        SnowparkContainerJobStatus.RUNNING,
-        SnowparkContainerJobStatus.CANCELLING,
-        SnowparkContainerJobStatus.SUSPENDING,
-        SnowparkContainerJobStatus.DELETING,
-    }
-)
-
-
 class SnowparkContainerJobOperator(BaseOperator):
     """
     Execute a job on Snowpark Container Services.
@@ -94,12 +68,18 @@ class SnowparkContainerJobOperator(BaseOperator):
         When disabled, the job is submitted and the operator returns
         immediately. (default value: True)
     :param drop_on_completion: drop the job service after the job finishes
-        successfully. Failed jobs are not dropped, allowing inspection
-        in Snowflake. (default value: True)
+        successfully or on a timeout. Failed jobs are not dropped, allowing
+        inspection in Snowflake. (default value: True)
     :param poll_interval: the interval in seconds to poll the query status.
         (default value: 10)
     :param snowflake_conn_id: Reference to
         :ref:`Snowflake connection id<howto/connection:snowflake>`
+    :param deferrable: Run the operator in deferrable mode. Only effective when
+        ``wait_for_completion`` is True. With ``wait_for_completion=False`` the
+        operator submits the job and returns immediately without deferring.
+        (default value: False)
+    :param timeout: Maximum seconds to wait for the job to reach a terminal
+        state. When it elapses the task fails. (default value: 86400)
     :param database: name of database (will overwrite database defined
         in connection)
     :param schema: name of schema (will overwrite schema defined in
@@ -137,6 +117,8 @@ class SnowparkContainerJobOperator(BaseOperator):
         drop_on_completion: bool = True,
         poll_interval: int = 10,
         snowflake_conn_id: str = "snowflake_default",
+        deferrable: bool = conf.getboolean("operators", "default_deferrable", 
fallback=False),
+        timeout: int = 24 * 60 * 60,
         database: str | None = None,
         schema: str | None = None,
         role: str | None = None,
@@ -158,6 +140,8 @@ class SnowparkContainerJobOperator(BaseOperator):
         self.drop_on_completion = drop_on_completion
         self.poll_interval = poll_interval
         self.snowflake_conn_id = snowflake_conn_id
+        self.deferrable = deferrable
+        self.timeout = timeout
         self.database = database
         self.schema = schema
         self.role = role
@@ -165,6 +149,9 @@ class SnowparkContainerJobOperator(BaseOperator):
         # Set after the job is submitted, parsed from the job submission 
response.
         self.job_name: str | None = None
 
+        if self.deferrable and not self.wait_for_completion:
+            self.log.warning("deferrable has no effect when 
wait_for_completion is False.")
+
     @cached_property
     def _hook(self) -> SnowflakeHook:
         return SnowflakeHook(
@@ -203,7 +190,14 @@ class SnowparkContainerJobOperator(BaseOperator):
 
     def _poll_for_status(self) -> str:
         """Poll until the job reaches a terminal state."""
+        status = None
+        end_time = time.monotonic() + self.timeout
         while True:
+            if time.monotonic() >= end_time:
+                self._log_container_output(status)
+                if self.drop_on_completion:
+                    self._drop_service()
+                raise TimeoutError(f"Job {self.job_name} did not reach a 
terminal status before the timeout.")
             response = self._run_one(f"DESCRIBE SERVICE {self.job_name}", 
return_dictionaries=True)
             status = response.get("status")
             if status in TERMINAL_STATUSES:
@@ -212,11 +206,15 @@ class SnowparkContainerJobOperator(BaseOperator):
                 raise RuntimeError(f"Job {self.job_name} returned unexpected 
status: {status}")
             time.sleep(self.poll_interval)
 
-    def _log_container_output(self, status: str) -> None:
-        """Fetch and log container output for all replicas."""
+    def _log_container_output(self, status: str | None) -> None:
+        """Fetch and log container output for all replicas. Best-effort so it 
never blocks cleanup."""
         for instance_id in range(self.replicas):
             sql = f"SELECT SYSTEM$GET_SERVICE_LOGS('{self.job_name}', 
{instance_id}, '{self.container_name}')"
-            response = self._run_one(sql)[0]
+            try:
+                response = self._run_one(sql)[0]
+            except Exception as e:
+                self.log.warning("Could not retrieve logs for instance_id %d: 
%s", instance_id, e)
+                continue
             if not response:
                 continue
             if status != SnowparkContainerJobStatus.DONE:
@@ -224,13 +222,27 @@ class SnowparkContainerJobOperator(BaseOperator):
             else:
                 self.log.info("Logs for instance_id %d:\n%s", instance_id, 
response)
 
+    def _drop_service(self) -> None:
+        """Best-effort drop of the job service."""
+        try:
+            self._hook.run(f"DROP SERVICE IF EXISTS {self.job_name}")
+        except Exception as e:
+            self.log.error("Error dropping service %s: %s", self.job_name, e)
+
     def on_kill(self) -> None:
         """Drop the running service on task kill."""
         if self.job_name:
-            try:
-                self._hook.run(f"DROP SERVICE IF EXISTS {self.job_name}")
-            except Exception as e:
-                self.log.error("Error dropping service %s: %s", self.job_name, 
e)
+            self._drop_service()
+
+    def _handle_final_status(self, status: str) -> None:
+        """Log container output, fail unless the job is DONE, and optionally 
drop the service on success."""
+        self._log_container_output(status)
+        if status != SnowparkContainerJobStatus.DONE:
+            raise RuntimeError(f"Job '{self.job_name}' finished with status: 
{status}")
+        if self.drop_on_completion:
+            # Job already succeeded, so a cleanup failure is logged rather 
than raised
+            # to avoid marking a successful job as failed.
+            self._drop_service()
 
     def execute(self, context: Context) -> str:
         """Submit and optionally wait for a Snowpark Container Services job."""
@@ -242,10 +254,51 @@ class SnowparkContainerJobOperator(BaseOperator):
             raise RuntimeError("Job name was not returned")
         if not self.wait_for_completion:
             return self.job_name
+        if self.deferrable:
+            # timeout and execution_timeout give the trigger two separate 
deadlines. timeout caps
+            # how long the job is polled, and execution_timeout, when set, 
enforces the task-level
+            # limit. The trigger times out on whichever is reached first.
+            now = time.time()
+            poll_buffer = timedelta(seconds=self.poll_interval + 60)
+            execution_deadline = None
+            defer_timeout = timedelta(seconds=self.timeout) + poll_buffer
+            if self.execution_timeout is not None:
+                # Hand the execution deadline to the trigger so it emits a 
timeout event that drops the
+                # service. The framework's defer timeout would otherwise kill 
the task with no cleanup.
+                execution_deadline = (
+                    context["ti"].start_date.timestamp() + 
self.execution_timeout.total_seconds()
+                )
+                # Pad the backstop past that deadline so the trigger fires 
first.
+                defer_timeout = self.execution_timeout + poll_buffer
+            self.defer(
+                trigger=SnowparkContainerJobTrigger(
+                    job_name=self.job_name,
+                    snowflake_conn_id=self.snowflake_conn_id,
+                    poll_interval=self.poll_interval,
+                    end_time=now + self.timeout,
+                    execution_deadline=execution_deadline,
+                    database=self.database,
+                    schema=self.schema,
+                    role=self.role,
+                    warehouse=self.warehouse,
+                ),
+                timeout=defer_timeout,
+                method_name="execute_complete",
+            )
         status = self._poll_for_status()
-        self._log_container_output(status)
-        if status != SnowparkContainerJobStatus.DONE:
-            raise RuntimeError(f"Job '{self.job_name}' finished with status: 
{status}")
-        if self.drop_on_completion:
-            self._hook.run(f"DROP SERVICE IF EXISTS {self.job_name}")
+        self._handle_final_status(status)
+        return self.job_name
+
+    def execute_complete(self, context: Context, event: dict[str, Any]) -> str:
+        """Resume after the trigger fires."""
+        self.job_name = event["job_name"]
+        status = event["status"]
+        if status == "timeout":
+            self._log_container_output(status)
+            if self.drop_on_completion:
+                self._drop_service()
+            raise TimeoutError(event.get("message", f"Job '{self.job_name}' 
did not complete: {status}"))
+        if status == "error":
+            raise RuntimeError(event.get("message", f"Job '{self.job_name}' 
did not complete: {status}"))
+        self._handle_final_status(status)
         return self.job_name
diff --git 
a/providers/snowflake/src/airflow/providers/snowflake/triggers/snowpark_containers.py
 
b/providers/snowflake/src/airflow/providers/snowflake/triggers/snowpark_containers.py
new file mode 100644
index 00000000000..4b1c6e2d1be
--- /dev/null
+++ 
b/providers/snowflake/src/airflow/providers/snowflake/triggers/snowpark_containers.py
@@ -0,0 +1,166 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements.  See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership.  The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License.  You may obtain a copy of the License at
+#
+#   http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied.  See the License for the
+# specific language governing permissions and limitations
+# under the License.
+from __future__ import annotations
+
+import asyncio
+import time
+from collections.abc import AsyncIterator
+from typing import Any
+
+from airflow.providers.common.sql.hooks.handlers import fetch_one_handler
+from airflow.providers.snowflake.hooks.snowflake import SnowflakeHook
+from airflow.providers.snowflake.utils.snowpark_containers import (
+    NON_TERMINAL_STATUSES,
+    TERMINAL_STATUSES,
+)
+from airflow.triggers.base import BaseTrigger, TriggerEvent
+
+
+class SnowparkContainerJobTrigger(BaseTrigger):
+    """
+    Poll a Snowpark Container Services job until it reaches a terminal status.
+
+    :param job_name: name of the submitted job service to poll.
+    :param snowflake_conn_id: reference to the Snowflake connection id.
+    :param poll_interval: seconds to sleep between ``DESCRIBE SERVICE`` polls.
+    :param end_time: epoch deadline (``time.time()`` seconds) after which a 
``timeout``
+        event is emitted.
+    :param execution_deadline: (Optional) absolute timestamp (in seconds since 
the epoch) after
+        which the task is considered timed out. (default: None)
+    :param database: (Optional) name of database. (default: None)
+    :param schema: (Optional) name of schema. (default: None)
+    :param role: (Optional) name of role. (default: None)
+    :param warehouse: (Optional) name of warehouse. (default: None)
+    """
+
+    def __init__(
+        self,
+        job_name: str,
+        snowflake_conn_id: str,
+        poll_interval: float,
+        end_time: float,
+        execution_deadline: float | None = None,
+        database: str | None = None,
+        schema: str | None = None,
+        role: str | None = None,
+        warehouse: str | None = None,
+    ) -> None:
+        super().__init__()
+        self.job_name = job_name
+        self.snowflake_conn_id = snowflake_conn_id
+        self.poll_interval = poll_interval
+        self.end_time = end_time
+        self.execution_deadline = execution_deadline
+        self.database = database
+        self.schema = schema
+        self.role = role
+        self.warehouse = warehouse
+
+    def serialize(self) -> tuple[str, dict[str, Any]]:
+        """Serialize SnowparkContainerJobTrigger arguments and class path."""
+        return (
+            
"airflow.providers.snowflake.triggers.snowpark_containers.SnowparkContainerJobTrigger",
+            {
+                "job_name": self.job_name,
+                "snowflake_conn_id": self.snowflake_conn_id,
+                "poll_interval": self.poll_interval,
+                "end_time": self.end_time,
+                "execution_deadline": self.execution_deadline,
+                "database": self.database,
+                "schema": self.schema,
+                "role": self.role,
+                "warehouse": self.warehouse,
+            },
+        )
+
+    def _get_hook(self) -> SnowflakeHook:
+        """Build a ``SnowflakeHook`` from the trigger's connection settings."""
+        return SnowflakeHook(
+            snowflake_conn_id=self.snowflake_conn_id,
+            warehouse=self.warehouse,
+            database=self.database,
+            schema=self.schema,
+            role=self.role,
+        )
+
+    async def _describe_status(self, hook: SnowflakeHook) -> str | None:
+        """Return the job's current status via ``DESCRIBE SERVICE``, or 
``None`` if absent."""
+        # SnowflakeHook is synchronous. Run the blocking poll off the event 
loop so a
+        # single query does not stall every other trigger on this triggerer.
+        response: Any = await asyncio.to_thread(
+            hook.run,
+            f"DESCRIBE SERVICE {self.job_name}",
+            handler=fetch_one_handler,
+            return_dictionaries=True,
+        )
+        return response.get("status") if response else None
+
+    async def run(self) -> AsyncIterator[TriggerEvent]:
+        """Poll the job status and yield exactly one terminal event."""
+        hook = self._get_hook()
+        while True:
+            now = time.time()
+            if self.execution_deadline is not None and now >= 
self.execution_deadline:
+                yield TriggerEvent(
+                    {
+                        "status": "timeout",
+                        "job_name": self.job_name,
+                        "message": f"Job {self.job_name} reached the execution 
timeout.",
+                    }
+                )
+                return
+
+            if now >= self.end_time:
+                yield TriggerEvent(
+                    {
+                        "status": "timeout",
+                        "job_name": self.job_name,
+                        "message": f"Job {self.job_name} did not reach a 
terminal status before the timeout.",
+                    }
+                )
+                return
+
+            try:
+                status = await self._describe_status(hook=hook)
+            except Exception as e:
+                yield TriggerEvent({"status": "error", "job_name": 
self.job_name, "message": str(e)})
+                return
+
+            if status in TERMINAL_STATUSES:
+                yield TriggerEvent({"status": status, "job_name": 
self.job_name})
+                return
+
+            if status not in NON_TERMINAL_STATUSES:
+                yield TriggerEvent(
+                    {
+                        "status": "error",
+                        "job_name": self.job_name,
+                        "message": f"Job {self.job_name} returned unexpected 
status: {status}",
+                    }
+                )
+                return
+
+            await asyncio.sleep(self.poll_interval)
+
+    async def on_kill(self) -> None:
+        """Drop the job service when a deferred task is killed."""
+        hook = self._get_hook()
+        try:
+            await asyncio.to_thread(hook.run, f"DROP SERVICE IF EXISTS 
{self.job_name}")
+            self.log.info("on_kill: dropped service %s", self.job_name)
+        except Exception as e:
+            self.log.error("on_kill: failed to drop service %s: %s", 
self.job_name, e)
diff --git 
a/providers/snowflake/src/airflow/providers/snowflake/utils/snowpark_containers.py
 
b/providers/snowflake/src/airflow/providers/snowflake/utils/snowpark_containers.py
new file mode 100644
index 00000000000..001446123b1
--- /dev/null
+++ 
b/providers/snowflake/src/airflow/providers/snowflake/utils/snowpark_containers.py
@@ -0,0 +1,52 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements.  See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership.  The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License.  You may obtain a copy of the License at
+#
+#   http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied.  See the License for the
+# specific language governing permissions and limitations
+# under the License.
+from __future__ import annotations
+
+from enum import Enum
+
+
+class SnowparkContainerJobStatus(str, Enum):
+    """Statuses of a Snowpark Container Services job service."""
+
+    PENDING = "PENDING"
+    RUNNING = "RUNNING"
+    CANCELLING = "CANCELLING"
+    SUSPENDING = "SUSPENDING"
+    DELETING = "DELETING"
+    DONE = "DONE"
+    FAILED = "FAILED"
+    CANCELLED = "CANCELLED"
+    INTERNAL_ERROR = "INTERNAL_ERROR"
+
+
+TERMINAL_STATUSES: frozenset[SnowparkContainerJobStatus] = frozenset(
+    {
+        SnowparkContainerJobStatus.DONE,
+        SnowparkContainerJobStatus.FAILED,
+        SnowparkContainerJobStatus.CANCELLED,
+        SnowparkContainerJobStatus.INTERNAL_ERROR,
+    }
+)
+NON_TERMINAL_STATUSES: frozenset[SnowparkContainerJobStatus] = frozenset(
+    {
+        SnowparkContainerJobStatus.PENDING,
+        SnowparkContainerJobStatus.RUNNING,
+        SnowparkContainerJobStatus.CANCELLING,
+        SnowparkContainerJobStatus.SUSPENDING,
+        SnowparkContainerJobStatus.DELETING,
+    }
+)
diff --git 
a/providers/snowflake/tests/unit/snowflake/operators/test_snowpark_containers.py
 
b/providers/snowflake/tests/unit/snowflake/operators/test_snowpark_containers.py
index c154c529025..12262c22901 100644
--- 
a/providers/snowflake/tests/unit/snowflake/operators/test_snowpark_containers.py
+++ 
b/providers/snowflake/tests/unit/snowflake/operators/test_snowpark_containers.py
@@ -16,11 +16,14 @@
 # under the License.
 from __future__ import annotations
 
+from datetime import datetime, timedelta, timezone
 from unittest import mock
 
 import pytest
 
+from airflow.providers.common.compat.sdk import TaskDeferred
 from airflow.providers.snowflake.operators.snowpark_containers import 
SnowparkContainerJobOperator
+from airflow.providers.snowflake.triggers.snowpark_containers import 
SnowparkContainerJobTrigger
 
 TASK_ID = "test_spcs_job"
 COMPUTE_POOL = "test_pool"
@@ -75,6 +78,21 @@ class TestSnowparkContainerJobOperator:
         with pytest.raises(ValueError, match=match):
             op.execute(context=None)
 
+    @pytest.mark.parametrize(
+        ("deferrable", "wait_for_completion", "warns"),
+        (
+            pytest.param(True, False, True, id="deferrable_no_wait"),
+            pytest.param(True, True, False, id="deferrable_wait"),
+            pytest.param(False, False, False, id="sync_no_wait"),
+        ),
+    )
+    @mock.patch.object(SnowparkContainerJobOperator, "log")
+    def test_warns_when_deferrable_without_wait_for_completion(
+        self, mock_log, deferrable, wait_for_completion, warns
+    ):
+        _make_operator(deferrable=deferrable, 
wait_for_completion=wait_for_completion)
+        assert mock_log.warning.called is warns
+
     def test_build_sql_with_spec_stage(self):
         op = _make_operator()
         sql = op._build_sql()
@@ -150,6 +168,32 @@ class TestSnowparkContainerJobOperator:
         assert op._poll_for_status() == "DONE"
         assert mock_sleep.call_count == 2
 
+    @pytest.mark.parametrize(
+        ("drop_on_completion", "drops"),
+        [(True, True), (False, False)],
+    )
+    @mock.patch("time.sleep")
+    @mock.patch("time.monotonic", side_effect=itertools.count(0, 20))
+    @mock.patch(MOCK_HOOK_PATH)
+    @mock.patch.object(SnowparkContainerJobOperator, "_log_container_output")
+    def test_poll_raises_and_logs_on_timeout(
+        self, mock_log, mock_hook_cls, mock_monotonic, sleep_mock, 
drop_on_completion, drops
+    ):
+        mock_hook = mock_hook_cls.return_value
+        mock_hook.run.return_value = {"status": "RUNNING"}
+        op = _make_operator(poll_interval=5, timeout=10, 
drop_on_completion=drop_on_completion)
+        op.job_name = JOB_NAME
+
+        with pytest.raises(TimeoutError, match="did not reach a terminal 
status"):
+            op._poll_for_status()
+
+        mock_log.assert_called_once_with("RUNNING")
+        drop_call = mock.call(f"DROP SERVICE IF EXISTS {JOB_NAME}")
+        if drops:
+            assert drop_call in mock_hook.run.call_args_list
+        else:
+            assert drop_call not in mock_hook.run.call_args_list
+
     @mock.patch(MOCK_HOOK_PATH)
     def test_log_container_output_uses_info_on_done(self, mock_hook_cls):
         mock_hook = mock_hook_cls.return_value
@@ -191,6 +235,16 @@ class TestSnowparkContainerJobOperator:
             op._log_container_output("DONE")
         assert mock_info.call_count == 3
 
+    @mock.patch(MOCK_HOOK_PATH)
+    def test_log_container_output_swallows_fetch_error(self, mock_hook_cls):
+        mock_hook = mock_hook_cls.return_value
+        mock_hook.run.side_effect = Exception("Unable to retrieve logs")
+        op = _make_operator()
+        op.job_name = JOB_NAME
+        with mock.patch.object(op.log, "warning") as mock_warning:
+            op._log_container_output("RUNNING")
+        mock_warning.assert_called_once_with("Could not retrieve logs for 
instance_id %d: %s", 0, mock.ANY)
+
     @mock.patch(MOCK_HOOK_PATH)
     def test_on_kill_no_job_name(self, mock_hook_cls):
         mock_hook = mock_hook_cls.return_value
@@ -280,3 +334,102 @@ class TestSnowparkContainerJobOperator:
         op = _make_operator(drop_on_completion=False)
         op.execute(context=None)
         mock_hook.run.assert_not_called()
+
+    @mock.patch.object(SnowparkContainerJobOperator, "_submit_job", 
return_value=JOB_NAME)
+    def test_execute_defers_when_deferrable(self, mock_submit):
+        op = _make_operator(deferrable=True)
+        with pytest.raises(TaskDeferred) as exc:
+            op.execute(context=None)
+        assert isinstance(exc.value.trigger, SnowparkContainerJobTrigger)
+        assert exc.value.trigger.job_name == JOB_NAME
+        assert exc.value.method_name == "execute_complete"
+
+    @mock.patch.object(SnowparkContainerJobOperator, "_submit_job", 
return_value=JOB_NAME)
+    def test_execute_defer_without_execution_timeout(self, mock_submit):
+        op = _make_operator(deferrable=True, timeout=100, poll_interval=10)
+        with pytest.raises(TaskDeferred) as exc:
+            op.execute(context=None)
+        assert exc.value.trigger.execution_deadline is None
+        assert exc.value.timeout == timedelta(seconds=100 + 10 + 60)
+
+    @mock.patch.object(SnowparkContainerJobOperator, "_submit_job", 
return_value=JOB_NAME)
+    def 
test_execute_defer_uses_execution_timeout_for_deadline_and_buffer(self, 
mock_submit, time_machine):
+        time_machine.move_to(1000, tick=False)
+        context = {"ti": mock.Mock(start_date=datetime.fromtimestamp(1000, 
tz=timezone.utc))}
+        op = _make_operator(
+            deferrable=True,
+            timeout=3600,
+            poll_interval=10,
+            execution_timeout=timedelta(seconds=120),
+        )
+        with pytest.raises(TaskDeferred) as exc:
+            op.execute(context=context)
+        assert exc.value.trigger.end_time == 1000 + 3600
+        assert exc.value.trigger.execution_deadline == 1000 + 120
+        assert exc.value.timeout == timedelta(seconds=120 + 10 + 60)
+
+    @mock.patch(MOCK_HOOK_PATH)
+    @mock.patch.object(SnowparkContainerJobOperator, "_log_container_output")
+    def test_execute_complete_success_drops_and_returns(self, mock_log, 
mock_hook_cls):
+        mock_hook = mock_hook_cls.return_value
+        op = _make_operator(drop_on_completion=True)
+        result = op.execute_complete(context=None, event={"status": "DONE", 
"job_name": JOB_NAME})
+        assert result == JOB_NAME
+        mock_log.assert_called_once_with("DONE")
+        mock_hook.run.assert_called_once_with(f"DROP SERVICE IF EXISTS 
{JOB_NAME}")
+
+    @mock.patch(MOCK_HOOK_PATH)
+    @mock.patch.object(SnowparkContainerJobOperator, "_log_container_output")
+    def test_execute_complete_failure_raises_without_drop(self, mock_log, 
mock_hook_cls):
+        mock_hook = mock_hook_cls.return_value
+        op = _make_operator()
+        with pytest.raises(RuntimeError, match="FAILED"):
+            op.execute_complete(context=None, event={"status": "FAILED", 
"job_name": JOB_NAME})
+        mock_log.assert_called_once_with("FAILED")
+        mock_hook.run.assert_not_called()
+
+    @pytest.mark.parametrize(
+        ("status", "exc", "drops", "logs"),
+        [("timeout", TimeoutError, True, True), ("error", RuntimeError, False, 
False)],
+    )
+    @mock.patch(MOCK_HOOK_PATH)
+    @mock.patch.object(SnowparkContainerJobOperator, "_log_container_output")
+    def test_execute_complete_raises_and_drops_only_on_timeout(
+        self, mock_log, mock_hook_cls, status, exc, drops, logs
+    ):
+        mock_hook = mock_hook_cls.return_value
+        op = _make_operator()
+        with pytest.raises(exc, match="boom"):
+            op.execute_complete(
+                context=None,
+                event={"status": status, "job_name": JOB_NAME, "message": 
"boom"},
+            )
+        if drops:
+            mock_hook.run.assert_called_once_with(f"DROP SERVICE IF EXISTS 
{JOB_NAME}")
+        else:
+            mock_hook.run.assert_not_called()
+        if logs:
+            mock_log.assert_called_once_with(status)
+        else:
+            mock_log.assert_not_called()
+
+    @mock.patch(MOCK_HOOK_PATH)
+    @mock.patch.object(SnowparkContainerJobOperator, "_log_container_output")
+    def test_execute_complete_skips_drop_when_disabled(self, mock_log, 
mock_hook_cls):
+        mock_hook = mock_hook_cls.return_value
+        op = _make_operator(drop_on_completion=False)
+        op.execute_complete(context=None, event={"status": "DONE", "job_name": 
JOB_NAME})
+        mock_hook.run.assert_not_called()
+
+    @mock.patch(MOCK_HOOK_PATH)
+    @mock.patch.object(SnowparkContainerJobOperator, "_log_container_output")
+    def test_execute_complete_timeout_skips_drop_when_disabled(self, mock_log, 
mock_hook_cls):
+        mock_hook = mock_hook_cls.return_value
+        op = _make_operator(drop_on_completion=False)
+        with pytest.raises(TimeoutError, match="boom"):
+            op.execute_complete(
+                context=None,
+                event={"status": "timeout", "job_name": JOB_NAME, "message": 
"boom"},
+            )
+        mock_log.assert_called_once_with("timeout")
+        mock_hook.run.assert_not_called()
diff --git 
a/providers/snowflake/tests/unit/snowflake/triggers/test_snowpark_containers.py 
b/providers/snowflake/tests/unit/snowflake/triggers/test_snowpark_containers.py
new file mode 100644
index 00000000000..f0e29e4515d
--- /dev/null
+++ 
b/providers/snowflake/tests/unit/snowflake/triggers/test_snowpark_containers.py
@@ -0,0 +1,205 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements.  See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership.  The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License.  You may obtain a copy of the License at
+#
+#   http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied.  See the License for the
+# specific language governing permissions and limitations
+# under the License.
+from __future__ import annotations
+
+import time
+from unittest import mock
+
+import pytest
+
+from airflow.providers.snowflake.triggers.snowpark_containers import 
SnowparkContainerJobTrigger
+from airflow.triggers.base import TriggerEvent
+
+TRIGGER_PATH = "airflow.providers.snowflake.triggers.snowpark_containers"
+CLASSPATH = f"{TRIGGER_PATH}.SnowparkContainerJobTrigger"
+HOOK = f"{TRIGGER_PATH}.SnowflakeHook"
+
+JOB_NAME = "TEST_JOB"
+CONN_ID = "snowflake_default"
+POLL_INTERVAL = 1.0
+
+
+class TestSnowparkContainerJobTrigger:
+    @staticmethod
+    def _describe(status):
+        return {"status": status}
+
+    def _trigger(self, end_time=None, **kwargs):
+        params = {
+            "job_name": JOB_NAME,
+            "snowflake_conn_id": CONN_ID,
+            "poll_interval": POLL_INTERVAL,
+            "end_time": end_time if end_time is not None else time.time() + 
3600,
+        }
+        params.update(kwargs)
+        return SnowparkContainerJobTrigger(**params)
+
+    def test_serialization(self):
+        end_time = time.time() + 3600
+        execution_deadline = time.time() + 1800
+        class_path, kwargs = self._trigger(
+            end_time,
+            execution_deadline=execution_deadline,
+            database="db",
+            schema="sc",
+            role="r",
+            warehouse="wh",
+        ).serialize()
+        assert class_path == CLASSPATH
+        assert kwargs == {
+            "job_name": JOB_NAME,
+            "snowflake_conn_id": CONN_ID,
+            "poll_interval": POLL_INTERVAL,
+            "end_time": end_time,
+            "execution_deadline": execution_deadline,
+            "database": "db",
+            "schema": "sc",
+            "role": "r",
+            "warehouse": "wh",
+        }
+
+    @mock.patch(HOOK, autospec=True)
+    def test_get_hook_builds_hook_from_connection_settings(self, 
mock_hook_cls):
+        self._trigger(database="db", schema="sc", role="r", 
warehouse="wh")._get_hook()
+        mock_hook_cls.assert_called_once_with(
+            snowflake_conn_id=CONN_ID,
+            warehouse="wh",
+            database="db",
+            schema="sc",
+            role="r",
+        )
+
+    @pytest.mark.asyncio
+    @mock.patch(HOOK, autospec=True)
+    async def test_describe_status_returns_none_when_no_row(self, 
mock_hook_cls):
+        mock_hook_cls.return_value.run.return_value = None
+        trigger = self._trigger()
+        assert await trigger._describe_status(trigger._get_hook()) is None
+
+    @pytest.mark.asyncio
+    @pytest.mark.parametrize("status", ("DONE", "FAILED", "CANCELLED", 
"INTERNAL_ERROR"))
+    @mock.patch(HOOK, autospec=True)
+    async def test_terminal_status_yields_status_event(self, mock_hook_cls, 
status):
+        mock_hook_cls.return_value.run.return_value = self._describe(status)
+        event = await self._trigger().run().__anext__()
+        assert event == TriggerEvent({"status": status, "job_name": JOB_NAME})
+
+    @pytest.mark.asyncio
+    @mock.patch(HOOK, autospec=True)
+    async def test_unexpected_status_yields_error(self, mock_hook_cls):
+        mock_hook_cls.return_value.run.return_value = self._describe("RANDOM")
+        event = await self._trigger().run().__anext__()
+        assert event.payload["status"] == "error"
+        assert "unexpected status" in event.payload["message"]
+
+    @pytest.mark.asyncio
+    @mock.patch(HOOK, autospec=True)
+    async def test_timeout_yields_timeout_event(self, mock_hook_cls):
+        mock_hook_cls.return_value.run.return_value = self._describe("RUNNING")
+        event = await self._trigger(end_time=time.time() - 1).run().__anext__()
+        assert event.payload["status"] == "timeout"
+        assert event.payload["job_name"] == JOB_NAME
+
+    @pytest.mark.asyncio
+    @mock.patch(HOOK, autospec=True)
+    async def test_execution_deadline_yields_timeout(self, mock_hook_cls):
+        mock_hook_cls.return_value.run.return_value = self._describe("RUNNING")
+        trigger = self._trigger(end_time=time.time() + 3600, 
execution_deadline=time.time() - 1)
+        event = await trigger.run().__anext__()
+        assert event.payload["status"] == "timeout"
+        assert "execution timeout" in event.payload["message"]
+
+    @pytest.mark.asyncio
+    @pytest.mark.parametrize(
+        ("end_time", "execution_deadline"),
+        (
+            (500.0, None),
+            (2000.0, 500.0),
+        ),
+    )
+    @mock.patch(HOOK, autospec=True)
+    async def test_deadline_takes_precedence_over_terminal_status(
+        self, mock_hook_cls, end_time, execution_deadline, time_machine
+    ):
+        time_machine.move_to(1000, tick=False)
+        mock_hook_cls.return_value.run.return_value = self._describe("DONE")
+        trigger = self._trigger(end_time=end_time, 
execution_deadline=execution_deadline)
+        event = await trigger.run().__anext__()
+        assert event.payload["status"] == "timeout"
+        mock_hook_cls.return_value.run.assert_not_called()
+
+    @pytest.mark.asyncio
+    @pytest.mark.parametrize(
+        ("end_time", "execution_deadline"),
+        (
+            (1000.0, None),
+            (2000.0, 1000.0),
+        ),
+    )
+    @mock.patch(HOOK, autospec=True)
+    async def test_deadline_boundary_counts_as_expired(
+        self, mock_hook_cls, end_time, execution_deadline, time_machine
+    ):
+        time_machine.move_to(1000, tick=False)
+        mock_hook_cls.return_value.run.return_value = self._describe("RUNNING")
+        trigger = self._trigger(end_time=end_time, 
execution_deadline=execution_deadline)
+        event = await trigger.run().__anext__()
+        assert event.payload["status"] == "timeout"
+        mock_hook_cls.return_value.run.assert_not_called()
+
+    @pytest.mark.asyncio
+    @mock.patch(HOOK, autospec=True)
+    async def test_future_execution_deadline_does_not_short_circuit(self, 
mock_hook_cls):
+        mock_hook_cls.return_value.run.return_value = self._describe("DONE")
+        trigger = self._trigger(end_time=time.time() + 3600, 
execution_deadline=time.time() + 1800)
+        event = await trigger.run().__anext__()
+        assert event == TriggerEvent({"status": "DONE", "job_name": JOB_NAME})
+
+    @pytest.mark.asyncio
+    @mock.patch(HOOK, autospec=True)
+    async def test_poll_exception_yields_error(self, mock_hook_cls):
+        mock_hook_cls.return_value.run.side_effect = RuntimeError("boom")
+        event = await self._trigger().run().__anext__()
+        assert event == TriggerEvent({"status": "error", "job_name": JOB_NAME, 
"message": "boom"})
+
+    @pytest.mark.asyncio
+    @mock.patch(f"{TRIGGER_PATH}.asyncio.sleep")
+    @mock.patch(HOOK, autospec=True)
+    async def test_polls_through_non_terminal_then_terminal(self, 
mock_hook_cls, mock_sleep):
+        mock_hook_cls.return_value.run.side_effect = [
+            self._describe("PENDING"),
+            self._describe("RUNNING"),
+            self._describe("DONE"),
+        ]
+        event = await self._trigger().run().__anext__()
+        assert event == TriggerEvent({"status": "DONE", "job_name": JOB_NAME})
+        assert mock_sleep.await_count == 2
+
+    @pytest.mark.asyncio
+    @mock.patch(HOOK, autospec=True)
+    async def test_on_kill_drops_service(self, mock_hook_cls):
+        await self._trigger().on_kill()
+        mock_hook_cls.return_value.run.assert_called_once_with(f"DROP SERVICE 
IF EXISTS {JOB_NAME}")
+
+    @pytest.mark.asyncio
+    @mock.patch(HOOK, autospec=True)
+    async def test_on_kill_logs_error_on_failure(self, mock_hook_cls):
+        mock_hook_cls.return_value.run.side_effect = RuntimeError("drop 
failed")
+        trigger = self._trigger()
+        with mock.patch.object(trigger.log, "error") as mock_error:
+            await trigger.on_kill()
+        mock_error.assert_called_once()
diff --git 
a/providers/snowflake/tests/unit/snowflake/utils/test_snowpark_containers.py 
b/providers/snowflake/tests/unit/snowflake/utils/test_snowpark_containers.py
new file mode 100644
index 00000000000..3358d9ea782
--- /dev/null
+++ b/providers/snowflake/tests/unit/snowflake/utils/test_snowpark_containers.py
@@ -0,0 +1,28 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements.  See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership.  The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License.  You may obtain a copy of the License at
+#
+#   http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied.  See the License for the
+# specific language governing permissions and limitations
+# under the License.
+from __future__ import annotations
+
+from airflow.providers.snowflake.utils.snowpark_containers import (
+    NON_TERMINAL_STATUSES,
+    TERMINAL_STATUSES,
+    SnowparkContainerJobStatus,
+)
+
+
+def test_each_status_is_terminal_or_non_terminal():
+    assert TERMINAL_STATUSES.isdisjoint(NON_TERMINAL_STATUSES)
+    assert set(SnowparkContainerJobStatus) == TERMINAL_STATUSES | 
NON_TERMINAL_STATUSES

Reply via email to