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 c173545e8d0 Surface YARN diagnostics for spark on yarn clusters 
(#70183)
c173545e8d0 is described below

commit c173545e8d0ffb45274855332ce39ab166698e7b
Author: Amogh Desai <[email protected]>
AuthorDate: Wed Jul 22 17:10:41 2026 +0530

    Surface YARN diagnostics for spark on yarn clusters (#70183)
---
 .../providers/apache/spark/hooks/spark_submit.py   | 24 ++++++---
 .../unit/apache/spark/hooks/test_spark_submit.py   | 57 +++++++++++++++++++++-
 2 files changed, 72 insertions(+), 9 deletions(-)

diff --git 
a/providers/apache/spark/src/airflow/providers/apache/spark/hooks/spark_submit.py
 
b/providers/apache/spark/src/airflow/providers/apache/spark/hooks/spark_submit.py
index 6d90f7b003b..cd4a0703d19 100644
--- 
a/providers/apache/spark/src/airflow/providers/apache/spark/hooks/spark_submit.py
+++ 
b/providers/apache/spark/src/airflow/providers/apache/spark/hooks/spark_submit.py
@@ -907,7 +907,7 @@ class SparkSubmitHook(BaseHook, LoggingMixin):
         while True:
             self.log.debug("Polling YARN RM REST API for application %s", 
application_id)
             try:
-                state, final_status = 
self._query_yarn_application_status(application_id)
+                state, final_status, diagnostics = 
self._query_yarn_application_status(application_id)
             except RuntimeError as exc:
                 consecutive_failures += 1
                 if consecutive_failures > max_consecutive_failures:
@@ -933,20 +933,23 @@ class SparkSubmitHook(BaseHook, LoggingMixin):
             elif poll_count % heartbeat_interval == 0:
                 self.log.info("YARN application %s is still %s", 
application_id, state)
 
+            diagnostics_suffix = f"\nDiagnostics: {diagnostics}" if 
diagnostics else ""
             if state in self._YARN_FINAL_FAILURES:
                 raise RuntimeError(
                     f"YARN application {application_id} ended with state: 
{state}, "
-                    f"final status: {final_status}"
+                    f"final status: {final_status}{diagnostics_suffix}"
                 )
             if final_status == self._YARN_FINAL_SUCCESS:
                 return
             if final_status in self._YARN_FINAL_FAILURES:
                 raise RuntimeError(
                     f"YARN application {application_id} ended with final 
status: {final_status}"
+                    f"{diagnostics_suffix}"
                 )
             if final_status != self._YARN_FINAL_UNDEFINED:
                 raise RuntimeError(
-                    f"YARN application {application_id} returned unexpected 
final status: {final_status}"
+                    f"YARN application {application_id} returned unexpected 
final status: "
+                    f"{final_status}{diagnostics_suffix}"
                 )
             time.sleep(poll_interval)
 
@@ -1001,8 +1004,15 @@ class SparkSubmitHook(BaseHook, LoggingMixin):
 
         return None
 
-    def _query_yarn_application_status(self, application_id: str) -> 
tuple[str, str]:
-        """GET ``/ws/v1/cluster/apps/{id}`` once and return ``app.state`` and 
``app.finalStatus``."""
+    def _query_yarn_application_status(self, application_id: str) -> 
tuple[str, str, str]:
+        """
+        GET ``/ws/v1/cluster/apps/{id}`` once.
+
+        Returns ``app.state``, ``app.finalStatus``, and ``app.diagnostics`` - 
diagnostics is
+        where YARN puts the actual human readable failure reason (AM launch 
error, container
+        OOM, explicit kill, etc.), so failure exceptions can include it 
instead of just the
+        two terminal-state enum values.
+        """
         url = 
f"{self._get_yarn_rm_base_url()}/ws/v1/cluster/apps/{application_id}"
         try:
             resp = requests.get(url, auth=self._resolved_yarn_rm_auth, 
timeout=self._HTTP_TIMEOUT)
@@ -1017,7 +1027,7 @@ class SparkSubmitHook(BaseHook, LoggingMixin):
             )
         try:
             app = resp.json()["app"]
-            return app["state"], app["finalStatus"]
+            return app["state"], app["finalStatus"], app.get("diagnostics", "")
         except (ValueError, KeyError, TypeError) as exc:
             raise RuntimeError(
                 f"YARN RM REST API returned unexpected payload for application 
"
@@ -1362,7 +1372,7 @@ class SparkSubmitHook(BaseHook, LoggingMixin):
             - FINISHED + any other finalStatus -> "FAILED"
             - FAILED or KILLED -> "FAILED"
         """
-        state, final_status = 
self._query_yarn_application_status(application_id)
+        state, final_status, _ = 
self._query_yarn_application_status(application_id)
         if state in {"NEW", "NEW_SAVING", "SUBMITTED", "ACCEPTED", "RUNNING"}:
             return state
         if state == "FINISHED" and final_status == self._YARN_FINAL_SUCCESS:
diff --git 
a/providers/apache/spark/tests/unit/apache/spark/hooks/test_spark_submit.py 
b/providers/apache/spark/tests/unit/apache/spark/hooks/test_spark_submit.py
index 90f923905fa..5733f44fd6b 100644
--- a/providers/apache/spark/tests/unit/apache/spark/hooks/test_spark_submit.py
+++ b/providers/apache/spark/tests/unit/apache/spark/hooks/test_spark_submit.py
@@ -1666,10 +1666,15 @@ class TestSparkSubmitHook:
         return f"{cls._RM_BASE_URL}/ws/v1/cluster/apps/{app_id or 
cls._RM_APP_ID}/state"
 
     @classmethod
-    def _rm_status_resp(cls, final_status: str, state: str = "FINISHED") -> 
MagicMock:
+    def _rm_status_resp(
+        cls, final_status: str, state: str = "FINISHED", diagnostics: str | 
None = None
+    ) -> MagicMock:
         resp = MagicMock(spec=requests.Response)
         resp.status_code = 200
-        resp.json.return_value = {"app": {"id": cls._RM_APP_ID, "state": 
state, "finalStatus": final_status}}
+        app = {"id": cls._RM_APP_ID, "state": state, "finalStatus": 
final_status}
+        if diagnostics is not None:
+            app["diagnostics"] = diagnostics
+        resp.json.return_value = {"app": app}
         return resp
 
     @staticmethod
@@ -1773,6 +1778,44 @@ class TestSparkSubmitHook:
 
         mock_sleep.assert_not_called()
 
+    @patch("airflow.providers.apache.spark.hooks.spark_submit.time.sleep")
+    @patch("airflow.providers.apache.spark.hooks.spark_submit.requests.get")
+    def test_yarn_status_tracking_includes_diagnostics_on_state_failure(self, 
mock_get, mock_sleep):
+        """RM state FAILED/KILLED -> raised message includes the RM's 
diagnostics field."""
+        mock_get.return_value = self._rm_status_resp(
+            "KILLED",
+            state="KILLED",
+            diagnostics="Application application_1700000000000_0001 was killed 
by user root",
+        )
+
+        hook = SparkSubmitHook(conn_id="spark_yarn_rm", 
yarn_track_via_rm_api=True)
+        with pytest.raises(RuntimeError, match="Diagnostics: Application .* 
was killed by user root"):
+            hook._start_yarn_application_status_tracking(self._RM_APP_ID)
+
+    @patch("airflow.providers.apache.spark.hooks.spark_submit.time.sleep")
+    @patch("airflow.providers.apache.spark.hooks.spark_submit.requests.get")
+    def 
test_yarn_status_tracking_includes_diagnostics_on_final_status_failure(self, 
mock_get, mock_sleep):
+        """RM finalStatus FAILED (state FINISHED) -> raised message includes 
diagnostics."""
+        mock_get.return_value = self._rm_status_resp(
+            "FAILED", diagnostics="AM Container exited with exitCode: 1"
+        )
+
+        hook = SparkSubmitHook(conn_id="spark_yarn_rm", 
yarn_track_via_rm_api=True)
+        with pytest.raises(RuntimeError, match="Diagnostics: AM Container 
exited with exitCode: 1"):
+            hook._start_yarn_application_status_tracking(self._RM_APP_ID)
+
+    @patch("airflow.providers.apache.spark.hooks.spark_submit.time.sleep")
+    @patch("airflow.providers.apache.spark.hooks.spark_submit.requests.get")
+    def test_yarn_status_tracking_omits_diagnostics_suffix_when_absent(self, 
mock_get, mock_sleep):
+        """RM response with no diagnostics field -> message has no 
'Diagnostics:' suffix."""
+        mock_get.return_value = self._rm_status_resp("KILLED")
+
+        hook = SparkSubmitHook(conn_id="spark_yarn_rm", 
yarn_track_via_rm_api=True)
+        with pytest.raises(RuntimeError) as exc_info:
+            hook._start_yarn_application_status_tracking(self._RM_APP_ID)
+
+        assert "Diagnostics:" not in str(exc_info.value)
+
     @patch("airflow.providers.apache.spark.hooks.spark_submit.time.sleep")
     @patch("airflow.providers.apache.spark.hooks.spark_submit.requests.get")
     def test_yarn_status_tracking_fails_on_unexpected_final_status(self, 
mock_get, mock_sleep):
@@ -1785,6 +1828,16 @@ class TestSparkSubmitHook:
 
         mock_sleep.assert_not_called()
 
+    @patch("airflow.providers.apache.spark.hooks.spark_submit.time.sleep")
+    @patch("airflow.providers.apache.spark.hooks.spark_submit.requests.get")
+    def 
test_yarn_status_tracking_includes_diagnostics_on_unexpected_final_status(self, 
mock_get, mock_sleep):
+        """RM returns a non-standard finalStatus -> raised message also 
includes diagnostics."""
+        mock_get.return_value = self._rm_status_resp("ENDED", 
diagnostics="Application state is ENDED")
+
+        hook = SparkSubmitHook(conn_id="spark_yarn_rm", 
yarn_track_via_rm_api=True)
+        with pytest.raises(RuntimeError, match="unexpected final status: 
ENDED\nDiagnostics: .*ENDED"):
+            hook._start_yarn_application_status_tracking(self._RM_APP_ID)
+
     
@patch("airflow.providers.apache.spark.hooks.spark_submit.subprocess.Popen")
     def 
test_yarn_submit_captures_app_id_without_submitted_application_log(self, 
mock_popen):
         """App ID parsed from log lines other than 'Submitted application ...' 
is captured by hook.submit()."""

Reply via email to