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 f8b8461e819 Make SparkSubmitOperator durable execution inert below 
Airflow 3.3 (#71534)
f8b8461e819 is described below

commit f8b8461e8191f88e72ad8c05b248e0385c21db99
Author: Amogh Desai <[email protected]>
AuthorDate: Tue Aug 18 10:50:14 2026 +0530

    Make SparkSubmitOperator durable execution inert below Airflow 3.3 (#71534)
---
 providers/apache/spark/docs/operators.rst          | 15 +++++++--
 .../apache/spark/operators/spark_submit.py         | 37 ++++++++++++++++------
 .../apache/spark/operators/test_spark_submit.py    | 26 +++++++++++++--
 3 files changed, 64 insertions(+), 14 deletions(-)

diff --git a/providers/apache/spark/docs/operators.rst 
b/providers/apache/spark/docs/operators.rst
index e0060f6db02..6da0e87fce6 100644
--- a/providers/apache/spark/docs/operators.rst
+++ b/providers/apache/spark/docs/operators.rst
@@ -212,8 +212,12 @@ The reconnection polling calls the Spark standalone REST 
API
 See :doc:`connections/spark-submit` for how to configure these fields.
 
 .. note::
-    Crash recovery in cluster mode requires Airflow 3.3+ (``task_state_store`` 
support). On earlier
-    versions the operator falls back to the previous behavior of always 
submitting fresh.
+    Crash recovery in cluster mode requires Airflow 3.3+ (``task_state_store`` 
support). Below
+    3.3, ``durable`` has no effect: setting it explicitly only emits a 
warning, and the operator
+    always submits fresh, exactly as before this feature existed. The 
deprecated
+    ``reconnect_on_retry`` parameter (the original name for this same feature, 
superseded almost
+    immediately) still emits a deprecation warning on every Airflow version 
and maps onto
+    ``durable``.
 
 Clearing a task is treated the same as a retry, which matters specifically for 
a task whose driver
 already succeeded: clearing does not delete the stored driver ID, so the next 
attempt reads it
@@ -302,6 +306,13 @@ the application is submitted:
         yarn_track_via_rm_api=True,
     )
 
+On Airflow 3.3+, YARN cluster mode with ``durable=True`` (the default) requires
+``yarn_track_via_rm_api=True`` -- the ResourceManager REST API is what makes 
checking application
+status on retry possible. Without it, the operator raises a ``ValueError`` at 
task start rather
+than silently falling back to a fire-and-forget submission. Below 3.3, 
``durable`` has no effect
+at all, so this requirement doesn't apply there either: durable execution 
isn't active to have a
+prerequisite for.
+
 For Kerberized clusters, install ``requests-kerberos`` in the Airflow 
environment. When the
 Spark connection has both ``keytab`` and ``principal`` configured, Airflow 
automatically uses
 ``HTTPKerberosAuth()`` for the ResourceManager REST requests.
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 48d70155326..1a9d2748a65 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
@@ -37,21 +37,33 @@ try:
 except ImportError:
     kube_client = None  # type: ignore[assignment]
 
+_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:
-    # Airflow 2 compat.
-    # ResumableJobMixin does not exist in Airflow 2, so we need to add a stub 
to make it
-    # behave as before
+    # ResumableJobMixin only exists on Airflow 3.3+; this provider still 
targets older
+    # versions. Drop this fallback once the provider's minimum Airflow version 
is >=3.3.
     class ResumableJobMixin:  # type: ignore[no-redef]
-        """Airflow 2 stub — no task_state_store, always submits fresh."""
+        """Airflow <3.3 stub, task_state_store unavailable, always submits 
fresh."""
 
         external_id_key: str = "remote_job_id"
 
-        def __init__(self, *, durable: bool = True, **kwargs: Any) -> None:
-            # Accept durable so the kwarg doesn't leak to BaseOperator; crash 
recovery is a no-op here.
+        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)
@@ -326,6 +338,9 @@ class SparkSubmitOperator(ResumableJobMixin, BaseOperator):
     :param durable: When ``True`` (the default), the external job ID is 
persisted to task state
         store before polling begins so that a worker crash and retry 
reconnects to the existing job
         instead of submitting a fresh one. Set to ``False`` to always submit a 
new job on retry.
+        Requires Airflow 3.3 or newer; below that, ``durable`` has no effect 
-- setting it
+        explicitly only emits a warning.
+    :param reconnect_on_retry: deprecated, use ``durable`` instead.
     """
 
     # Generic key used across all Spark deployment modes (standalone driver ID,
@@ -405,13 +420,15 @@ class SparkSubmitOperator(ResumableJobMixin, 
BaseOperator):
     ) -> None:
         if reconnect_on_retry is not None:
             warnings.warn(
-                "reconnect_on_retry is renamed to durable.",
+                "`reconnect_on_retry` is deprecated and will be removed once 
this provider's "
+                "minimum supported Airflow version reaches 3.3. Use `durable` 
instead.",
                 AirflowProviderDeprecationWarning,
                 stacklevel=2,
             )
-            kwargs.setdefault("durable", reconnect_on_retry)
+            if durable is None:
+                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.
+        # supported Airflow version.
         if durable is not None:
             kwargs["durable"] = durable
         super().__init__(**kwargs)
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 3b525582ed7..c55cb10f3eb 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
@@ -28,7 +28,11 @@ import pytest
 from airflow.exceptions import AirflowProviderDeprecationWarning
 from airflow.models import DagRun, TaskInstance
 from airflow.models.dag import DAG
-from airflow.providers.apache.spark.operators.spark_submit import 
SparkSubmitOperator
+from airflow.providers.apache.spark.operators.spark_submit import (
+    _DURABLE_UNSET,
+    SparkSubmitOperator,
+    _warn_and_disable_durable_pre_3_3,
+)
 from airflow.providers.common.compat.sdk import timezone
 from airflow.utils.types import DagRunType
 
@@ -598,7 +602,10 @@ class TestSparkSubmitOperatorResumable:
             operator = self._make_operator(reconnect_on_retry=False)
         assert len(w) == 1
         assert issubclass(w[0].category, AirflowProviderDeprecationWarning)
-        assert "reconnect_on_retry" in str(w[0].message)
+        assert str(w[0].message) == (
+            "`reconnect_on_retry` is deprecated and will be removed once this 
provider's "
+            "minimum supported Airflow version reaches 3.3. Use `durable` 
instead."
+        )
         assert operator.durable is False
 
     def test_default_args_durable_reaches_operator(self):
@@ -1129,3 +1136,18 @@ class TestSparkSubmitOperatorK8sTracking:
         operator.execute(context={"task_state_store": task_store})
 
         assert task_store.get("spark_job_id") is None
+
+
+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