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 f5bd40ed261 Make `durable` reach `default_args` and warn when set 
below Airflow 3.3 (#71531)
f5bd40ed261 is described below

commit f5bd40ed261c127c7666c0422de648a7f519ad01
Author: Amogh Desai <[email protected]>
AuthorDate: Fri Aug 14 11:22:01 2026 +0530

    Make `durable` reach `default_args` and warn when set below Airflow 3.3 
(#71531)
---
 .../docs/operators/redshift/redshift_data.rst      |  4 +++
 .../amazon/aws/operators/redshift_data.py          | 24 +++++++++++++--
 .../amazon/aws/operators/test_redshift_data.py     | 34 +++++++++++++++++++++-
 .../providers/apache/livy/operators/livy.py        | 24 +++++++++++++--
 .../tests/unit/apache/livy/operators/test_livy.py  | 28 +++++++++++++++++-
 .../apache/spark/operators/spark_submit.py         |  5 ++++
 .../apache/spark/operators/test_spark_submit.py    |  4 +++
 providers/databricks/docs/operators/run_now.rst    |  4 +++
 providers/databricks/docs/operators/submit_run.rst |  4 +++
 .../providers/databricks/operators/databricks.py   | 29 ++++++++++++++++--
 .../unit/databricks/operators/test_databricks.py   | 28 ++++++++++++++++++
 providers/google/docs/operators/cloud/bigquery.rst |  8 ++---
 .../providers/google/cloud/operators/bigquery.py   | 23 +++++++++++++--
 .../unit/google/cloud/operators/test_bigquery.py   | 22 ++++++++++++++
 providers/snowflake/docs/operators/snowflake.rst   |  4 +++
 .../providers/snowflake/operators/snowflake.py     | 24 +++++++++++++--
 .../unit/snowflake/operators/test_snowflake.py     | 22 ++++++++++++++
 17 files changed, 275 insertions(+), 16 deletions(-)

diff --git a/providers/amazon/docs/operators/redshift/redshift_data.rst 
b/providers/amazon/docs/operators/redshift/redshift_data.rst
index 35d3bf19a07..f4e100e3bdd 100644
--- a/providers/amazon/docs/operators/redshift/redshift_data.rst
+++ b/providers/amazon/docs/operators/redshift/redshift_data.rst
@@ -125,6 +125,10 @@ Durable execution applies to the synchronous path. When 
``deferrable=True`` is s
 already tracks the statement across the wait, so deferrable mode takes 
precedence and ``durable``
 has no effect.
 
+Durable execution requires Airflow 3.3 or newer, since it relies on the task 
state store. Below
+3.3, ``durable`` has no effect either way: setting it explicitly only emits a 
warning, and the
+operator always submits fresh SQL on retry, exactly as before this feature 
existed.
+
 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 b81403be214..f385fcc1761 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,6 +17,7 @@
 # under the License.
 from __future__ import annotations
 
+import warnings
 from typing import TYPE_CHECKING, Any, cast
 
 import botocore.exceptions
@@ -28,6 +29,20 @@ 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
 
+_DURABLE_UNSET = object()
+
+
+def _warn_and_disable_durable_pre_3_3(durable: Any) -> bool:
+    """Shared by the <3.3 compat stub: durable has no effect below 3.3, warn 
if it was set."""
+    if durable is not _DURABLE_UNSET:
+        warnings.warn(
+            "`durable` has no effect on Airflow versions below 3.3.",
+            UserWarning,
+            stacklevel=3,
+        )
+    return False
+
+
 try:
     from airflow.sdk import ResumableJobMixin
 except ImportError:
@@ -37,9 +52,9 @@ except ImportError:
 
         external_id_key: str = "redshift_statement_id"
 
-        def __init__(self, *, durable: bool = True, **kwargs: Any) -> None:
+        def __init__(self, *, durable: Any = _DURABLE_UNSET, **kwargs: Any) -> 
None:
             super().__init__(**kwargs)
-            self.durable = durable
+            self.durable = _warn_and_disable_durable_pre_3_3(durable)
 
         def execute_resumable(self, context):
             external_id = self.submit_job(context)
@@ -135,8 +150,13 @@ class RedshiftDataOperator(ResumableJobMixin, 
AwsBaseOperator[RedshiftDataHook])
         session_id: str | None = None,
         session_keep_alive_seconds: int | None = None,
         cancel_on_kill: bool = True,
+        durable: bool | None = None,
         **kwargs,
     ) -> None:
+        # Named here (not left to **kwargs) so default_args reaches it on every
+        # supported Airflow version.
+        if durable is not None:
+            kwargs["durable"] = durable
         super().__init__(**kwargs)
         self.database = database
         self.sql = sql
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 1b33c7ab183..7831cf51021 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
@@ -17,13 +17,20 @@
 # under the License.
 from __future__ import annotations
 
+import warnings
+from datetime import datetime
 from unittest import mock
 
 import botocore.exceptions
 import pytest
 
+from airflow.models.dag import DAG
 from airflow.providers.amazon.aws.hooks.redshift_data import 
QueryExecutionOutput
-from airflow.providers.amazon.aws.operators.redshift_data import 
RedshiftDataOperator
+from airflow.providers.amazon.aws.operators.redshift_data import (
+    _DURABLE_UNSET,
+    RedshiftDataOperator,
+    _warn_and_disable_durable_pre_3_3,
+)
 from airflow.providers.amazon.aws.triggers.redshift_data import 
RedshiftDataTrigger
 from airflow.providers.common.compat.sdk import AirflowException, TaskDeferred
 
@@ -721,3 +728,28 @@ class TestRedshiftDataOperatorDurable:
 
         assert operator.is_job_succeeded("FINISHED") is True
         assert operator.is_job_succeeded("STARTED") is False
+
+    def test_default_args_durable_reaches_operator(self):
+        with DAG(
+            dag_id="test_redshift_data_durable_default_args",
+            schedule=None,
+            start_date=datetime(2024, 1, 1),
+            default_args={"durable": False},
+        ):
+            operator = self._make_operator()
+        assert operator.durable is False
+
+
+class TestWarnAndDisableDurableAirflowPre3_3:
+    def test_no_warning_when_unset(self):
+        with warnings.catch_warnings(record=True) as caught:
+            warnings.simplefilter("always")
+            result = _warn_and_disable_durable_pre_3_3(_DURABLE_UNSET)
+        assert result is False
+        assert caught == []
+
+    @pytest.mark.parametrize("value", [True, False])
+    def test_warns_and_disables_when_explicitly_set(self, value):
+        with pytest.warns(UserWarning, match="durable.*no effect"):
+            result = _warn_and_disable_durable_pre_3_3(value)
+        assert result is False
diff --git 
a/providers/apache/livy/src/airflow/providers/apache/livy/operators/livy.py 
b/providers/apache/livy/src/airflow/providers/apache/livy/operators/livy.py
index 9f0a6b08506..b95b63cbd00 100644
--- a/providers/apache/livy/src/airflow/providers/apache/livy/operators/livy.py
+++ b/providers/apache/livy/src/airflow/providers/apache/livy/operators/livy.py
@@ -17,6 +17,7 @@
 from __future__ import annotations
 
 import time
+import warnings
 from collections.abc import Sequence
 from functools import cached_property
 from typing import TYPE_CHECKING, Any, cast
@@ -29,6 +30,20 @@ from airflow.providers.common.compat.openlineage.utils.spark 
import (
 )
 from airflow.providers.common.compat.sdk import AirflowException, 
BaseOperator, conf
 
+_DURABLE_UNSET = object()
+
+
+def _warn_and_disable_durable_pre_3_3(durable: Any) -> bool:
+    """Shared by the <3.3 compat stub: durable has no effect below 3.3, warn 
if it was set."""
+    if durable is not _DURABLE_UNSET:
+        warnings.warn(
+            "`durable` has no effect on Airflow versions below 3.3.",
+            UserWarning,
+            stacklevel=3,
+        )
+    return False
+
+
 # ResumableJobMixin ships in airflow.sdk, which only exists on Airflow 3, 
while this provider
 # still targets apache-airflow>=2.11. Guard the import and fall back to a stub 
on Airflow 2;
 # drop the fallback once the provider's minimum Airflow version is >=3.0.
@@ -41,10 +56,10 @@ except ImportError:
 
         external_id_key: str = "livy_batch_id"
 
-        def __init__(self, *, durable: bool = True, **kwargs: Any) -> None:
+        def __init__(self, *, durable: Any = _DURABLE_UNSET, **kwargs: Any) -> 
None:
             # Swallow ``durable`` so it doesn't reach BaseOperator; crash 
recovery is a no-op here.
             super().__init__(**kwargs)
-            self.durable = durable
+            self.durable = _warn_and_disable_durable_pre_3_3(durable)
 
         def execute_resumable(self, context):
             external_id = self.submit_job(context)
@@ -130,8 +145,13 @@ class LivyOperator(ResumableJobMixin, BaseOperator):
         openlineage_inject_transport_info: bool = conf.getboolean(
             "openlineage", "spark_inject_transport_info", fallback=False
         ),
+        durable: bool | None = None,
         **kwargs: Any,
     ) -> None:
+        # Named here (not left to **kwargs) so default_args reaches it on every
+        # supported Airflow version.
+        if durable is not None:
+            kwargs["durable"] = durable
         super().__init__(**kwargs)
 
         if conf is None:
diff --git 
a/providers/apache/livy/tests/unit/apache/livy/operators/test_livy.py 
b/providers/apache/livy/tests/unit/apache/livy/operators/test_livy.py
index 2914b85166f..7fb6d22b459 100644
--- a/providers/apache/livy/tests/unit/apache/livy/operators/test_livy.py
+++ b/providers/apache/livy/tests/unit/apache/livy/operators/test_livy.py
@@ -17,6 +17,7 @@
 from __future__ import annotations
 
 import logging
+import warnings
 from typing import Any
 from unittest.mock import MagicMock, patch
 
@@ -25,7 +26,11 @@ import pytest
 from airflow.models import Connection
 from airflow.models.dag import DAG
 from airflow.providers.apache.livy.hooks.livy import BatchState, LivyHook
-from airflow.providers.apache.livy.operators.livy import LivyOperator
+from airflow.providers.apache.livy.operators.livy import (
+    _DURABLE_UNSET,
+    LivyOperator,
+    _warn_and_disable_durable_pre_3_3,
+)
 from airflow.providers.common.compat.sdk import AirflowException, timezone
 
 from tests_common.test_utils.version_compat import AIRFLOW_V_3_3_PLUS
@@ -714,6 +719,12 @@ class TestLivyOperatorResumable:
         assert operator.is_job_succeeded("success") is True
         assert operator.is_job_succeeded("dead") is False
 
+    def test_default_args_durable_reaches_operator(self):
+        operator = LivyOperator(
+            task_id="livy_default_args", file="sparkapp.jar", 
default_args={"durable": False}
+        )
+        assert operator.durable is False
+
     def test_get_job_status_reads_batch_state_value(self):
         operator = self._make_operator()
         hook = self._make_hook()
@@ -731,3 +742,18 @@ class TestLivyOperatorResumable:
         operator.poll_until_complete(55, {})
 
         assert operator._batch_id == 55
+
+
+class TestWarnAndDisableDurableAirflowPre3_3:
+    def test_no_warning_when_unset(self):
+        with warnings.catch_warnings(record=True) as caught:
+            warnings.simplefilter("always")
+            result = _warn_and_disable_durable_pre_3_3(_DURABLE_UNSET)
+        assert result is False
+        assert caught == []
+
+    @pytest.mark.parametrize("value", [True, False])
+    def test_warns_and_disables_when_explicitly_set(self, value):
+        with pytest.warns(UserWarning, match="durable.*no effect"):
+            result = _warn_and_disable_durable_pre_3_3(value)
+        assert result is False
diff --git 
a/providers/apache/spark/src/airflow/providers/apache/spark/operators/spark_submit.py
 
b/providers/apache/spark/src/airflow/providers/apache/spark/operators/spark_submit.py
index d70729cb27f..48d70155326 100644
--- 
a/providers/apache/spark/src/airflow/providers/apache/spark/operators/spark_submit.py
+++ 
b/providers/apache/spark/src/airflow/providers/apache/spark/operators/spark_submit.py
@@ -400,6 +400,7 @@ class SparkSubmitOperator(ResumableJobMixin, BaseOperator):
             "openlineage", "spark_inject_transport_info", fallback=False
         ),
         reconnect_on_retry: bool | None = None,
+        durable: bool | None = None,
         **kwargs: Any,
     ) -> None:
         if reconnect_on_retry is not None:
@@ -409,6 +410,10 @@ class SparkSubmitOperator(ResumableJobMixin, BaseOperator):
                 stacklevel=2,
             )
             kwargs.setdefault("durable", reconnect_on_retry)
+        # Named here (not left to **kwargs) so default_args={"durable": ...} 
reaches it on every
+        # supported Airflow version; applied after reconnect_on_retry so an 
explicit durable wins.
+        if durable is not None:
+            kwargs["durable"] = durable
         super().__init__(**kwargs)
         self.application = application
         self.conf = conf
diff --git 
a/providers/apache/spark/tests/unit/apache/spark/operators/test_spark_submit.py 
b/providers/apache/spark/tests/unit/apache/spark/operators/test_spark_submit.py
index 953af3408d0..3b525582ed7 100644
--- 
a/providers/apache/spark/tests/unit/apache/spark/operators/test_spark_submit.py
+++ 
b/providers/apache/spark/tests/unit/apache/spark/operators/test_spark_submit.py
@@ -601,6 +601,10 @@ class TestSparkSubmitOperatorResumable:
         assert "reconnect_on_retry" in str(w[0].message)
         assert operator.durable is False
 
+    def test_default_args_durable_reaches_operator(self):
+        operator = self._make_operator(default_args={"durable": False})
+        assert operator.durable is False
+
     def test_durable_false_submits_fresh_and_polls(self):
         operator = self._make_operator(durable=False)
         operator._hook = self._make_hook(should_track=True)
diff --git a/providers/databricks/docs/operators/run_now.rst 
b/providers/databricks/docs/operators/run_now.rst
index ff34ccf6242..0b53f659b8a 100644
--- a/providers/databricks/docs/operators/run_now.rst
+++ b/providers/databricks/docs/operators/run_now.rst
@@ -136,6 +136,10 @@ Durable execution applies to the synchronous path. When 
``deferrable=True`` is s
 already tracks the run across the wait, so deferrable mode takes precedence 
and ``durable`` has no
 effect.
 
+Durable execution requires Airflow 3.3 or newer, since it relies on the task 
state store. Below
+3.3, ``durable`` has no effect either way: setting it explicitly only emits a 
warning, and the
+operator always triggers a fresh run on retry, exactly as before this feature 
existed.
+
 
 DatabricksRunNowDeferrableOperator
 ==================================
diff --git a/providers/databricks/docs/operators/submit_run.rst 
b/providers/databricks/docs/operators/submit_run.rst
index 76e1e15fcb8..82919d982c2 100644
--- a/providers/databricks/docs/operators/submit_run.rst
+++ b/providers/databricks/docs/operators/submit_run.rst
@@ -215,6 +215,10 @@ Durable execution applies to the synchronous path. When 
``deferrable=True`` is s
 Triggerer already tracks the run across the wait, so deferrable mode takes 
precedence and
 ``durable`` has no effect.
 
+Durable execution requires Airflow 3.3 or newer, since it relies on the task 
state store. Below
+3.3, ``durable`` has no effect either way: setting it explicitly only emits a 
warning, and the
+operator always submits a fresh run on retry, exactly as before this feature 
existed.
+
 
 DatabricksSubmitRunDeferrableOperator
 =====================================
diff --git 
a/providers/databricks/src/airflow/providers/databricks/operators/databricks.py 
b/providers/databricks/src/airflow/providers/databricks/operators/databricks.py
index d820b099c8d..34a0c47e511 100644
--- 
a/providers/databricks/src/airflow/providers/databricks/operators/databricks.py
+++ 
b/providers/databricks/src/airflow/providers/databricks/operators/databricks.py
@@ -24,6 +24,7 @@ import copy
 import hashlib
 import json as json_utils
 import time
+import warnings
 from abc import ABC, abstractmethod
 from collections.abc import Mapping, Sequence
 from functools import cached_property
@@ -68,6 +69,20 @@ if TYPE_CHECKING:
     from airflow.sdk import TaskGroup
     from airflow.sdk.types import Context, Logger
 
+_DURABLE_UNSET = object()
+
+
+def _warn_and_disable_durable_pre_3_3(durable: Any) -> bool:
+    """Shared by the <3.3 compat stub: durable has no effect below 3.3, warn 
if it was set."""
+    if durable is not _DURABLE_UNSET:
+        warnings.warn(
+            "`durable` has no effect on Airflow versions below 3.3.",
+            UserWarning,
+            stacklevel=3,
+        )
+    return False
+
+
 try:
     from airflow.sdk import ResumableJobMixin
 except ImportError:
@@ -77,9 +92,9 @@ except ImportError:
 
         external_id_key: str = "databricks_run_id"
 
-        def __init__(self, *, durable: bool = True, **kwargs: Any) -> None:
+        def __init__(self, *, durable: Any = _DURABLE_UNSET, **kwargs: Any) -> 
None:
             super().__init__(**kwargs)
-            self.durable = durable
+            self.durable = _warn_and_disable_durable_pre_3_3(durable)
 
         def execute_resumable(self, context):
             external_id = self.submit_job(context)
@@ -776,9 +791,14 @@ class DatabricksSubmitRunOperator(ResumableJobMixin, 
BaseOperator):
         openlineage_inject_transport_info: bool = conf.getboolean(
             "openlineage", "spark_inject_transport_info", fallback=False
         ),
+        durable: bool | None = None,
         **kwargs,
     ) -> None:
         """Create a new ``DatabricksSubmitRunOperator``."""
+        # Named here (not left to **kwargs) so default_args reaches it on every
+        # supported Airflow version.
+        if durable is not None:
+            kwargs["durable"] = durable
         super().__init__(**kwargs)
         self.json = json
         self.tasks = tasks
@@ -1246,9 +1266,14 @@ class DatabricksRunNowOperator(ResumableJobMixin, 
BaseOperator):
         databricks_repair_reason_new_settings: dict[str, Any] | None = None,
         cancel_previous_runs: bool = False,
         forward_dag_params: bool = True,
+        durable: bool | None = None,
         **kwargs,
     ) -> None:
         """Create a new ``DatabricksRunNowOperator``."""
+        # Named here (not left to **kwargs) so default_args reaches it on every
+        # supported Airflow version.
+        if durable is not None:
+            kwargs["durable"] = durable
         super().__init__(**kwargs)
         self.json = json
         self.job_id = job_id
diff --git 
a/providers/databricks/tests/unit/databricks/operators/test_databricks.py 
b/providers/databricks/tests/unit/databricks/operators/test_databricks.py
index 0f190da3661..a581bc117b3 100644
--- a/providers/databricks/tests/unit/databricks/operators/test_databricks.py
+++ b/providers/databricks/tests/unit/databricks/operators/test_databricks.py
@@ -19,6 +19,7 @@ from __future__ import annotations
 
 import copy
 import hashlib
+import warnings
 from datetime import datetime, timedelta
 from typing import Any
 from unittest import mock
@@ -40,6 +41,7 @@ from airflow.providers.common.compat.sdk import 
AirflowException, BaseOperator,
 from airflow.providers.databricks.exceptions import DatabricksApiError
 from airflow.providers.databricks.hooks.databricks import RunState, 
SQLStatementState
 from airflow.providers.databricks.operators.databricks import (
+    _DURABLE_UNSET,
     DatabricksCreateJobsOperator,
     DatabricksNotebookOperator,
     DatabricksRunNowOperator,
@@ -47,6 +49,7 @@ from airflow.providers.databricks.operators.databricks import 
(
     DatabricksSubmitRunOperator,
     DatabricksTaskBaseOperator,
     DatabricksTaskOperator,
+    _warn_and_disable_durable_pre_3_3,
 )
 from airflow.providers.databricks.triggers.databricks import (
     DatabricksExecutionTrigger,
@@ -1889,6 +1892,12 @@ class TestDatabricksSubmitRunOperatorDurable:
         op = DatabricksSubmitRunOperator(task_id=TASK_ID, 
json={"notebook_task": NOTEBOOK_TASK})
         assert op.is_job_succeeded(status) is expected
 
+    def test_default_args_durable_reaches_operator(self):
+        op = DatabricksSubmitRunOperator(
+            task_id=TASK_ID, json={"notebook_task": NOTEBOOK_TASK}, 
default_args={"durable": False}
+        )
+        assert op.durable is False
+
 
 class TestDatabricksRunNowOperator:
     def test_init_with_named_parameters(self):
@@ -3202,6 +3211,10 @@ class TestDatabricksRunNowOperatorDurable:
         op = DatabricksRunNowOperator(task_id=TASK_ID, job_id=JOB_ID)
         assert op.is_job_succeeded(status) is expected
 
+    def test_default_args_durable_reaches_operator(self):
+        op = DatabricksRunNowOperator(task_id=TASK_ID, job_id=JOB_ID, 
default_args={"durable": False})
+        assert op.durable is False
+
 
 class TestDatabricksSQLStatementsOperator:
     def test_init(self):
@@ -4800,3 +4813,18 @@ class TestDatabricksTaskOperator:
 
         operator.monitor_databricks_job()
         assert mock_databricks_hook.return_value.get_run.call_count == 3
+
+
+class TestWarnAndDisableDurableAirflowPre3_3:
+    def test_no_warning_when_unset(self):
+        with warnings.catch_warnings(record=True) as caught:
+            warnings.simplefilter("always")
+            result = _warn_and_disable_durable_pre_3_3(_DURABLE_UNSET)
+        assert result is False
+        assert caught == []
+
+    @pytest.mark.parametrize("value", [True, False])
+    def test_warns_and_disables_when_explicitly_set(self, value):
+        with pytest.warns(UserWarning, match="durable.*no effect"):
+            result = _warn_and_disable_durable_pre_3_3(value)
+        assert result is False
diff --git a/providers/google/docs/operators/cloud/bigquery.rst 
b/providers/google/docs/operators/cloud/bigquery.rst
index 6f42b4e841a..fa9b71a7337 100644
--- a/providers/google/docs/operators/cloud/bigquery.rst
+++ b/providers/google/docs/operators/cloud/bigquery.rst
@@ -403,10 +403,10 @@ the same logical run doesn't submit a second job, that 
guarantee is unrelated to
 and keeping ``force_rerun=False`` is still the right choice for it.
 
 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 a 
fresh job on retry,
-exactly as before -- including the pre-existing 
``reattach_states``/``Conflict`` behavior, which is
-unchanged. If the task state store is unavailable at runtime, the operator 
logs that crash
-recovery is disabled and behaves the same way.
+earlier Airflow versions the flag is a no-op -- setting it explicitly only 
emits a warning -- and
+the operator always submits a fresh job on retry, exactly as before -- 
including the pre-existing
+``reattach_states``/``Conflict`` behavior, which is unchanged. If the task 
state store is
+unavailable at runtime, the operator logs that crash recovery is disabled and 
behaves the same way.
 
 Like the persisted state itself, the stored job id isn't deleted 
automatically, that only happens
 when someone runs ``airflow state-store clean``. If a task's ``retry_delay`` 
is longer than
diff --git 
a/providers/google/src/airflow/providers/google/cloud/operators/bigquery.py 
b/providers/google/src/airflow/providers/google/cloud/operators/bigquery.py
index afc279e6548..872ff30fbff 100644
--- a/providers/google/src/airflow/providers/google/cloud/operators/bigquery.py
+++ b/providers/google/src/airflow/providers/google/cloud/operators/bigquery.py
@@ -73,6 +73,20 @@ except ImportError:
         return value is not NOTSET
 
 
+_DURABLE_UNSET = object()
+
+
+def _warn_and_disable_durable_pre_3_3(durable: Any) -> bool:
+    """Shared by the <3.3 compat stub: durable has no effect below 3.3, warn 
if it was set."""
+    if durable is not _DURABLE_UNSET:
+        warnings.warn(
+            "`durable` has no effect on Airflow versions below 3.3.",
+            UserWarning,
+            stacklevel=3,
+        )
+    return False
+
+
 try:
     from airflow.sdk import ResumableJobMixin
 except ImportError:
@@ -82,9 +96,9 @@ except ImportError:
 
         external_id_key: str = "bigquery_job_id"
 
-        def __init__(self, *, durable: bool = True, **kwargs: Any) -> None:
+        def __init__(self, *, durable: Any = _DURABLE_UNSET, **kwargs: Any) -> 
None:
             super().__init__(**kwargs)
-            self.durable = durable
+            self.durable = _warn_and_disable_durable_pre_3_3(durable)
 
         def execute_resumable(self, context):
             external_id = self.submit_job(context)
@@ -2373,8 +2387,13 @@ class BigQueryInsertJobOperator(
         result_timeout: float | None = None,
         deferrable: bool = conf.getboolean("operators", "default_deferrable", 
fallback=False),
         poll_interval: float = 4.0,
+        durable: bool | None = None,
         **kwargs,
     ) -> None:
+        # Named here (not left to **kwargs) so default_args reaches it on every
+        # supported Airflow version.
+        if durable is not None:
+            kwargs["durable"] = durable
         super().__init__(**kwargs)
         self.configuration = configuration
         self.location = location
diff --git 
a/providers/google/tests/unit/google/cloud/operators/test_bigquery.py 
b/providers/google/tests/unit/google/cloud/operators/test_bigquery.py
index 5d4e6718bf8..2c0d163373f 100644
--- a/providers/google/tests/unit/google/cloud/operators/test_bigquery.py
+++ b/providers/google/tests/unit/google/cloud/operators/test_bigquery.py
@@ -20,6 +20,7 @@ from __future__ import annotations
 import json
 import logging
 import os
+import warnings
 from contextlib import suppress
 from unittest import mock
 from unittest.mock import ANY, MagicMock
@@ -51,6 +52,7 @@ from airflow.providers.common.compat.sdk import (
 )
 from airflow.providers.google.cloud.openlineage.utils import BIGQUERY_NAMESPACE
 from airflow.providers.google.cloud.operators.bigquery import (
+    _DURABLE_UNSET,
     BigQueryCheckOperator,
     BigQueryColumnCheckOperator,
     BigQueryCreateEmptyDatasetOperator,
@@ -73,6 +75,7 @@ from airflow.providers.google.cloud.operators.bigquery import 
(
     BigQueryUpdateTableSchemaOperator,
     BigQueryUpsertTableOperator,
     BigQueryValueCheckOperator,
+    _warn_and_disable_durable_pre_3_3,
 )
 from airflow.providers.google.cloud.triggers.bigquery import (
     BigQueryCheckTrigger,
@@ -2560,6 +2563,10 @@ class TestBigQueryInsertJobOperatorDurable:
         assert op.is_job_succeeded("success") is True
         assert op.is_job_succeeded("RUNNING") is False
 
+    def test_default_args_durable_reaches_operator(self):
+        op = self._make_operator(default_args={"durable": False})
+        assert op.durable is False
+
 
 class TestBigQueryIntervalCheckOperator:
     def test_bigquery_interval_check_operator_execute_complete(self):
@@ -3439,3 +3446,18 @@ class TestBigQueryListRoutinesOperator:
         call_kwargs = mock_hook.return_value.list_routines.call_args.kwargs
         assert call_kwargs["dataset_id"] == TEST_DATASET
         assert call_kwargs["max_results"] == 10
+
+
+class TestWarnAndDisableDurableAirflowPre3_3:
+    def test_no_warning_when_unset(self):
+        with warnings.catch_warnings(record=True) as caught:
+            warnings.simplefilter("always")
+            result = _warn_and_disable_durable_pre_3_3(_DURABLE_UNSET)
+        assert result is False
+        assert caught == []
+
+    @pytest.mark.parametrize("value", [True, False])
+    def test_warns_and_disables_when_explicitly_set(self, value):
+        with pytest.warns(UserWarning, match="durable.*no effect"):
+            result = _warn_and_disable_durable_pre_3_3(value)
+        assert result is False
diff --git a/providers/snowflake/docs/operators/snowflake.rst 
b/providers/snowflake/docs/operators/snowflake.rst
index 113b1832b5b..4cb3059718c 100644
--- a/providers/snowflake/docs/operators/snowflake.rst
+++ b/providers/snowflake/docs/operators/snowflake.rst
@@ -214,3 +214,7 @@ To opt out and always submit fresh SQL on retry, set 
``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.
+
+Durable execution requires Airflow 3.3 or newer, since it relies on the task 
state store. Below
+3.3, ``durable`` has no effect either way: setting it explicitly only emits a 
warning, and the
+operator always submits fresh SQL on retry, exactly as before this feature 
existed.
diff --git 
a/providers/snowflake/src/airflow/providers/snowflake/operators/snowflake.py 
b/providers/snowflake/src/airflow/providers/snowflake/operators/snowflake.py
index 8e3cda63ef3..fa8b6348ca7 100644
--- a/providers/snowflake/src/airflow/providers/snowflake/operators/snowflake.py
+++ b/providers/snowflake/src/airflow/providers/snowflake/operators/snowflake.py
@@ -18,6 +18,7 @@
 from __future__ import annotations
 
 import time
+import warnings
 from collections.abc import Iterable, Mapping, Sequence
 from datetime import timedelta
 from functools import cached_property
@@ -35,6 +36,20 @@ 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
 
+_DURABLE_UNSET = object()
+
+
+def _warn_and_disable_durable_pre_3_3(durable: Any) -> bool:
+    """Shared by the <3.3 compat stub: durable has no effect below 3.3, warn 
if it was set."""
+    if durable is not _DURABLE_UNSET:
+        warnings.warn(
+            "`durable` has no effect on Airflow versions below 3.3.",
+            UserWarning,
+            stacklevel=3,
+        )
+    return False
+
+
 try:
     from airflow.sdk import ResumableJobMixin
 except ImportError:
@@ -44,9 +59,9 @@ except ImportError:
 
         external_id_key: str = "snowflake_query_ids"
 
-        def __init__(self, *, durable: bool = True, **kwargs: Any) -> None:
+        def __init__(self, *, durable: Any = _DURABLE_UNSET, **kwargs: Any) -> 
None:
             super().__init__(**kwargs)
-            self.durable = durable
+            self.durable = _warn_and_disable_durable_pre_3_3(durable)
 
         def execute_resumable(self, context):
             external_id = self.submit_job(context)
@@ -413,8 +428,13 @@ class SnowflakeSqlApiOperator(ResumableJobMixin, 
SQLExecuteQueryOperator):
         timeout: int | None = None,
         deferrable: bool = conf.getboolean("operators", "default_deferrable", 
fallback=False),
         snowflake_api_retry_args: dict[str, Any] | None = None,
+        durable: bool | None = None,
         **kwargs: Any,
     ) -> None:
+        # Named here (not left to **kwargs) so default_args reaches it on every
+        # supported Airflow version.
+        if durable is not None:
+            kwargs["durable"] = durable
         self.snowflake_conn_id = snowflake_conn_id
         self.poll_interval = poll_interval
         self.statement_count = statement_count
diff --git 
a/providers/snowflake/tests/unit/snowflake/operators/test_snowflake.py 
b/providers/snowflake/tests/unit/snowflake/operators/test_snowflake.py
index d37f420f2a6..7cf8713ab7d 100644
--- a/providers/snowflake/tests/unit/snowflake/operators/test_snowflake.py
+++ b/providers/snowflake/tests/unit/snowflake/operators/test_snowflake.py
@@ -17,6 +17,7 @@
 # under the License.
 from __future__ import annotations
 
+import warnings
 from unittest import mock
 from unittest.mock import MagicMock, call
 
@@ -31,10 +32,12 @@ from airflow.models.taskinstance import TaskInstance
 from airflow.providers.common.compat.sdk import TaskDeferred
 from airflow.providers.common.sql.operators.sql import SQLExecuteQueryOperator
 from airflow.providers.snowflake.operators.snowflake import (
+    _DURABLE_UNSET,
     SnowflakeCheckOperator,
     SnowflakeIntervalCheckOperator,
     SnowflakeSqlApiOperator,
     SnowflakeValueCheckOperator,
+    _warn_and_disable_durable_pre_3_3,
 )
 from airflow.providers.snowflake.triggers.snowflake_trigger import 
SnowflakeSqlApiTrigger
 from airflow.utils.types import DagRunType
@@ -967,3 +970,22 @@ class TestSnowflakeSqlApiOperatorDurable:
         assert operator.is_job_active("success") is False
         assert operator.is_job_succeeded("success") is True
         assert operator.is_job_succeeded("running") is False
+
+    def test_default_args_durable_reaches_operator(self):
+        operator = self._make_operator(default_args={"durable": False})
+        assert operator.durable is False
+
+
+class TestWarnAndDisableDurableAirflowPre3_3:
+    def test_no_warning_when_unset(self):
+        with warnings.catch_warnings(record=True) as caught:
+            warnings.simplefilter("always")
+            result = _warn_and_disable_durable_pre_3_3(_DURABLE_UNSET)
+        assert result is False
+        assert caught == []
+
+    @pytest.mark.parametrize("value", [True, False])
+    def test_warns_and_disables_when_explicitly_set(self, value):
+        with pytest.warns(UserWarning, match="durable.*no effect"):
+            result = _warn_and_disable_durable_pre_3_3(value)
+        assert result is False

Reply via email to