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 8596a0fb643 Decouple SparkSubmitOperator resumable deployment backends
(#68679)
8596a0fb643 is described below
commit 8596a0fb64394504e508979e76524f0d6962f75e
Author: Arnav <[email protected]>
AuthorDate: Thu Jul 30 02:47:42 2026 +0530
Decouple SparkSubmitOperator resumable deployment backends (#68679)
---
.../apache/spark/operators/spark_submit.py | 329 +++++++++++++--------
1 file changed, 201 insertions(+), 128 deletions(-)
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 290ec50cd91..096f9ca84fc 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
@@ -66,6 +66,183 @@ if TYPE_CHECKING:
from airflow.providers.common.compat.sdk import Context
+class _SparkSubmitDeploymentBackend:
+ """Base interface for private Spark submit deployment backends."""
+
+ def __init__(self, operator: SparkSubmitOperator, hook: SparkSubmitHook)
-> None:
+ self.operator = operator
+ self.hook = hook
+
+ def submit_job(self, context: Context) -> str | None:
+ raise NotImplementedError()
+
+ def get_job_status(self, external_id: str, context: Context) -> str:
+ raise NotImplementedError()
+
+ def is_job_active(self, status: str) -> bool:
+ raise NotImplementedError()
+
+ def is_job_succeeded(self, status: str) -> bool:
+ raise NotImplementedError()
+
+ def poll_until_complete(self, external_id: str, context: Context) -> None:
+ raise NotImplementedError()
+
+ def on_kill(self) -> None:
+ raise NotImplementedError()
+
+
+class _KubernetesSparkSubmitBackend(_SparkSubmitDeploymentBackend):
+ """Logic for tracking Spark driver pods in Kubernetes."""
+
+ def submit_job(self, context: Context) -> str | None:
+ self.hook._conf[_K8S_WAIT_APP_COMPLETION_CONF] = "false"
+ self.hook.submit(self.operator.application)
+ pod_name = self.hook._kubernetes_driver_pod
+ namespace = self.hook._connection["namespace"]
+ if not pod_name:
+ raise RuntimeError("spark-submit did not capture a K8s driver pod
name")
+ external_id = f"{namespace}:{pod_name}"
+ self.operator.log.info("Spark K8s driver pod submitted: %s",
external_id)
+ return external_id
+
+ def get_job_status(self, external_id: str, context: Context) -> str:
+ if (task_state_store := context.get("task_state_store")) is not None:
+ if (cached :=
task_state_store.get(self.operator._K8S_DRIVER_STATUS_KEY)) is not None:
+ if not isinstance(cached, str):
+ raise ValueError(f"Cached K8s driver status is not a
string: {cached!r}")
+ return cached
+ if kube_client is None:
+ raise RuntimeError("apache-airflow-providers-cncf-kubernetes is
required to query K8s pod status")
+ namespace, pod_name = self.operator._parse_k8s_external_id(external_id)
+ try:
+ client = kube_client.get_kube_client()
+ pod = client.read_namespaced_pod(pod_name, namespace)
+ return pod.status.phase or "Pending"
+ except kube_client.ApiException as e:
+ if e.status == 404:
+ return "NotFound"
+ raise
+
+ def is_job_active(self, status: str) -> bool:
+ return status.upper() in ("PENDING", "RUNNING")
+
+ def is_job_succeeded(self, status: str) -> bool:
+ return status.upper() == "SUCCEEDED"
+
+ def poll_until_complete(self, external_id: str, context: Context) -> None:
+ if external_id is not None:
+ _, pod_name = self.operator._parse_k8s_external_id(external_id)
+ self.hook._kubernetes_driver_pod = pod_name
+ terminal_phase = self.hook._poll_k8s_driver_via_api()
+ # Cache only when the pod actually reached Succeeded, the 404/vanished
path
+ # returns None for cases like: pod deleted by on_kill or garbage
collected after failure)
+ # and must not be cached, otherwise a retry would see "Succeeded" and
skip resubmission.
+ if terminal_phase == "Succeeded" and self.operator.durable:
+ if (task_state_store := context.get("task_state_store")) is not
None:
+ task_state_store.set(self.operator._K8S_DRIVER_STATUS_KEY,
"Succeeded")
+
+ def on_kill(self) -> None:
+ self.hook.on_kill()
+
+
+class _YarnSparkSubmitBackend(_SparkSubmitDeploymentBackend):
+ """Logic for tracking Spark applications in YARN cluster mode."""
+
+ def submit_job(self, context: Context) -> str | None:
+ if self.hook._conf.get("spark.yarn.submit.waitAppCompletion",
"").strip().lower() == "true":
+ raise ValueError(
+ "spark.yarn.submit.waitAppCompletion=true cannot be set for
cluster mode as it conflicts"
+ "with the need to exit spark-submit immediately to persist the
application ID for tracking. "
+ "Either remove the explicit conf or set durable=False."
+ )
+ self.hook._conf["spark.yarn.submit.waitAppCompletion"] = "false"
+ self.hook.submit(self.operator.application)
+ app_id = self.hook._yarn_application_id
+ if not app_id:
+ raise RuntimeError("spark-submit did not produce a YARN
application ID")
+ self.operator.log.info("YARN application submitted: %s", app_id)
+ return app_id
+
+ def get_job_status(self, external_id: str, context: Context) -> str:
+ return self.hook.query_yarn_application_status(external_id)
+
+ def is_job_active(self, status: str) -> bool:
+ #
https://hadoop.apache.org/docs/stable/hadoop-yarn/hadoop-yarn-site/ResourceManagerRest.html
+ return status.upper() in {"NEW", "NEW_SAVING", "SUBMITTED",
"ACCEPTED", "RUNNING"}
+
+ def is_job_succeeded(self, status: str) -> bool:
+ return status.upper() == "SUCCEEDED"
+
+ def poll_until_complete(self, external_id: str, context: Context) -> None:
+ try:
+ self.hook._start_yarn_application_status_tracking(external_id)
+ finally:
+ self.hook._run_post_submit_commands()
+
+ def on_kill(self) -> None:
+ if self.hook._yarn_application_id:
+ # spark-submit has already exited (waitAppCompletion=false), so
the hook's
+ # CLI-based kill has nothing to terminate. Kill the YARN app via
REST API instead.
+ self.hook._kill_yarn_application(self.hook._yarn_application_id)
+ else:
+ self.hook.on_kill()
+
+
+class _StandaloneSparkSubmitBackend(_SparkSubmitDeploymentBackend):
+ """Logic for tracking Spark driver status in Spark standalone mode."""
+
+ def submit_job(self, context: Context) -> str | None:
+ driver_id = self.hook.submit(self.operator.application)
+ if not driver_id:
+ raise RuntimeError("spark-submit did not return a driver ID")
+ self.operator.log.info("Spark driver submitted: %s", driver_id)
+ return driver_id
+
+ def get_job_status(self, external_id: str, context: Context) -> str:
+ scheme = self.hook._connection.get("rest_scheme", "http")
+ rest_port = self.hook._connection.get("rest_port", 6066)
+ # HA master URLs can look like spark://m1:7077,m2:7077 — try each host
in order.
+ # The master URL port (e.g. 7077) is the RPC port — not the REST API
port.
+ # Use rest-port connection extra to override spark.master.rest.port
(default 6066).
+ master_urls = self.hook._connection["master"].replace("spark://",
"").split(",")
+ last_exc: Exception = RuntimeError("No Spark masters to query")
+ for m in master_urls:
+ host = m.strip().split(":")[0]
+ url =
f"{scheme}://{host}:{rest_port}/v1/submissions/status/{external_id}"
+ try:
+ status = self.operator._fetch_driver_status(url, external_id)
+ return status
+ except Exception as e:
+ self.operator.log.warning("Could not reach Spark master %s:
%s", host, e)
+ last_exc = e
+ raise last_exc
+
+ def is_job_active(self, status: str) -> bool:
+ # RELAUNCHING: driver is being restarted after a failure, still alive.
+ # UNKNOWN: master is in failure recovery, state is temporarily
unavailable.
+ #
https://github.com/apache/spark/blob/master/core/src/main/scala/org/apache/spark/deploy/master/DriverState.scala
+ return status.upper() in ("SUBMITTED", "RUNNING", "RELAUNCHING",
"UNKNOWN")
+
+ def is_job_succeeded(self, status: str) -> bool:
+ # standalone and YARN both use FINISHED
+ return status.upper() == "FINISHED"
+
+ def poll_until_complete(self, external_id: str, context: Context) -> None:
+ self.operator.log.info("Polling driver %s until completion",
external_id)
+ self.hook._driver_id = external_id
+ try:
+ self.hook._start_driver_status_tracking()
+ if self.hook._driver_status != "FINISHED":
+ raise RuntimeError(f"Driver {external_id} exited with status
{self.hook._driver_status}")
+ finally:
+ # post-submit commands must fire whether the job succeeded or
failed.
+ self.hook._run_post_submit_commands()
+
+ def on_kill(self) -> None:
+ self.hook.on_kill()
+
+
class SparkSubmitOperator(ResumableJobMixin, BaseOperator):
"""
Wrap the spark-submit binary to kick off a spark-submit job; requires
"spark-submit" binary in the PATH.
@@ -262,6 +439,7 @@ class SparkSubmitOperator(ResumableJobMixin, BaseOperator):
self._yarn_queue = yarn_queue
self._deploy_mode = deploy_mode
self._hook: SparkSubmitHook | None = None
+ self.__backend: _SparkSubmitDeploymentBackend | None = None
self.post_submit_commands = post_submit_commands
self._conn_id = conn_id
self._use_krb5ccache = use_krb5ccache
@@ -301,82 +479,32 @@ class SparkSubmitOperator(ResumableJobMixin,
BaseOperator):
return self.execute_resumable(context)
hook.submit(self.application)
- def submit_job(self, context: Context) -> str | None:
+ @property
+ def _backend(self) -> _SparkSubmitDeploymentBackend:
+ if self.__backend is not None and self.__backend.hook is self._hook:
+ return self.__backend
+
if self._hook is None:
self._hook = self._get_hook()
- if self._hook._is_kubernetes:
- self._hook._conf[_K8S_WAIT_APP_COMPLETION_CONF] = "false"
- self._hook.submit(self.application)
- pod_name = self._hook._kubernetes_driver_pod
- namespace = self._hook._connection["namespace"]
- if not pod_name:
- raise RuntimeError("spark-submit did not capture a K8s driver
pod name")
- external_id = f"{namespace}:{pod_name}"
- self.log.info("Spark K8s driver pod submitted: %s", external_id)
- return external_id
+
+ backend: _SparkSubmitDeploymentBackend
if self._hook._is_yarn_cluster_mode:
- if self._hook._conf.get("spark.yarn.submit.waitAppCompletion",
"").strip().lower() == "true":
- raise ValueError(
- "spark.yarn.submit.waitAppCompletion=true cannot be set
for cluster mode as it conflicts"
- "with the need to exit spark-submit immediately to persist
the application ID for tracking. "
- "Either remove the explicit conf or set durable=False."
- )
- self._hook._conf["spark.yarn.submit.waitAppCompletion"] = "false"
- self._hook.submit(self.application)
- app_id = self._hook._yarn_application_id
- if not app_id:
- raise RuntimeError("spark-submit did not produce a YARN
application ID")
- self.log.info("YARN application submitted: %s", app_id)
- return app_id
- driver_id = self._hook.submit(self.application)
- if not driver_id:
- raise RuntimeError("spark-submit did not return a driver ID")
- self.log.info("Spark driver submitted: %s", driver_id)
- return driver_id
+ backend = _YarnSparkSubmitBackend(self, self._hook)
+ elif self._hook._is_kubernetes:
+ backend = _KubernetesSparkSubmitBackend(self, self._hook)
+ else:
+ backend = _StandaloneSparkSubmitBackend(self, self._hook)
+
+ self.__backend = backend
+ return self.__backend
+
+ def submit_job(self, context: Context) -> str | None:
+ return self._backend.submit_job(context)
def get_job_status(self, external_id: JsonValue, context: Context) -> str:
# called from submit_job which always returns a str (Spark driver IDs
are strings)
external_id = cast("str", external_id)
- if self._hook is None:
- self._hook = self._get_hook()
- if self._hook._is_yarn_cluster_mode:
- return self._hook.query_yarn_application_status(external_id)
- if self._hook._is_kubernetes:
- if (task_state_store := context.get("task_state_store")) is not
None:
- if (cached :=
task_state_store.get(self._K8S_DRIVER_STATUS_KEY)) is not None:
- if not isinstance(cached, str):
- raise ValueError(f"Cached K8s driver status is not a
string: {cached!r}")
- return cached
- if kube_client is None:
- raise RuntimeError(
- "apache-airflow-providers-cncf-kubernetes is required to
query K8s pod status"
- )
- namespace, pod_name = self._parse_k8s_external_id(external_id)
- try:
- client = kube_client.get_kube_client()
- pod = client.read_namespaced_pod(pod_name, namespace)
- return pod.status.phase or "Pending"
- except kube_client.ApiException as e:
- if e.status == 404:
- return "NotFound"
- raise
- scheme = self._hook._connection.get("rest_scheme", "http")
- rest_port = self._hook._connection.get("rest_port", 6066)
- # HA master URLs can look like spark://m1:7077,m2:7077 — try each host
in order.
- # The master URL port (e.g. 7077) is the RPC port — not the REST API
port.
- # Use rest-port connection extra to override spark.master.rest.port
(default 6066).
- master_urls = self._hook._connection["master"].replace("spark://",
"").split(",")
- last_exc: Exception = RuntimeError("No Spark masters to query")
- for m in master_urls:
- host = m.strip().split(":")[0]
- url =
f"{scheme}://{host}:{rest_port}/v1/submissions/status/{external_id}"
- try:
- status = self._fetch_driver_status(url, external_id)
- return status
- except Exception as e:
- self.log.warning("Could not reach Spark master %s: %s", host,
e)
- last_exc = e
- raise last_exc
+ return self._backend.get_job_status(external_id, context)
@staticmethod
def _parse_k8s_external_id(external_id: str) -> tuple[str, str]:
@@ -402,76 +530,21 @@ class SparkSubmitOperator(ResumableJobMixin,
BaseOperator):
return status
def is_job_active(self, status: str) -> bool:
- if self._hook is None:
- self._hook = self._get_hook()
- status = status.upper()
- if self._hook._is_yarn_cluster_mode:
- #
https://hadoop.apache.org/docs/stable/hadoop-yarn/hadoop-yarn-site/ResourceManagerRest.html
- return status in {"NEW", "NEW_SAVING", "SUBMITTED", "ACCEPTED",
"RUNNING"}
- if self._hook._is_kubernetes:
- return status in ("PENDING", "RUNNING")
- # RELAUNCHING: driver is being restarted after a failure, still alive.
- # UNKNOWN: master is in failure recovery, state is temporarily
unavailable.
- #
https://github.com/apache/spark/blob/master/core/src/main/scala/org/apache/spark/deploy/master/DriverState.scala
- return status in ("SUBMITTED", "RUNNING", "RELAUNCHING", "UNKNOWN")
+ return self._backend.is_job_active(status)
def is_job_succeeded(self, status: str) -> bool:
- if self._hook is None:
- self._hook = self._get_hook()
- status = status.upper()
- if self._hook._is_yarn_cluster_mode:
- return status == "SUCCEEDED"
- if self._hook._is_kubernetes:
- return status == "SUCCEEDED"
- # standalone and YARN both use FINISHED
- return status == "FINISHED"
+ return self._backend.is_job_succeeded(status)
def poll_until_complete(self, external_id: JsonValue, context: Context) ->
None:
# called from submit_job which always returns a str (Spark driver IDs
are strings)
external_id = cast("str", external_id)
- if self._hook is None:
- self._hook = self._get_hook()
- if self._hook._is_yarn_cluster_mode:
- try:
- self._hook._start_yarn_application_status_tracking(external_id)
- finally:
- self._hook._run_post_submit_commands()
- return
- if self._hook._is_kubernetes:
- if external_id is not None:
- _, pod_name = self._parse_k8s_external_id(external_id)
- self._hook._kubernetes_driver_pod = pod_name
- terminal_phase = self._hook._poll_k8s_driver_via_api()
- # Cache only when the pod actually reached Succeeded, the
404/vanished path
- # returns None for cases like: pod deleted by on_kill or garbage
collected after failure)
- # and must not be cached, otherwise a retry would see "Succeeded"
and skip resubmission.
- if terminal_phase == "Succeeded" and self.durable:
- if (task_state_store := context.get("task_state_store")) is
not None:
- task_state_store.set(self._K8S_DRIVER_STATUS_KEY,
"Succeeded")
- return
-
- self.log.info("Polling driver %s until completion", external_id)
- self._hook._driver_id = external_id
- try:
- self._hook._start_driver_status_tracking()
- if self._hook._driver_status != "FINISHED":
- raise RuntimeError(f"Driver {external_id} exited with status
{self._hook._driver_status}")
- finally:
- # post-submit commands must fire whether the job succeeded or
failed.
- self._hook._run_post_submit_commands()
+ return self._backend.poll_until_complete(external_id, context)
def get_job_result(self, external_id: JsonValue, context: Context) -> None:
return None
def on_kill(self) -> None:
- if self._hook is None:
- self._hook = self._get_hook()
- if self._hook._is_yarn_cluster_mode and
self._hook._yarn_application_id:
- # spark-submit has already exited (waitAppCompletion=false), so
the hook's
- # CLI-based kill has nothing to terminate. Kill the YARN app via
REST API instead.
- self._hook._kill_yarn_application(self._hook._yarn_application_id)
- else:
- self._hook.on_kill()
+ self._backend.on_kill()
def _get_hook(self) -> SparkSubmitHook:
return SparkSubmitHook(