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

eladkal 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 104ad12e190 Include spark submit canonical logs in failure exceptions 
(#70178)
104ad12e190 is described below

commit 104ad12e190db7197fa303d98a3ab68879eafd33
Author: Amogh Desai <[email protected]>
AuthorDate: Thu Jul 23 09:02:58 2026 +0530

    Include spark submit canonical logs in failure exceptions (#70178)
    
    * Include spark submit canonical logs in failure exceptions
    
    * comments from wei
---
 .../providers/apache/spark/hooks/spark_submit.py   | 20 +++++++
 .../unit/apache/spark/hooks/test_spark_submit.py   | 65 ++++++++++++++++++++++
 2 files changed, 85 insertions(+)

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 cd4a0703d19..5e198044aa5 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
@@ -27,6 +27,7 @@ import subprocess
 import tempfile
 import time
 import uuid
+from collections import deque
 from collections.abc import Iterator
 from functools import cached_property
 from pathlib import Path
@@ -318,6 +319,9 @@ class SparkSubmitHook(BaseHook, LoggingMixin):
         self._driver_id: str | None = None
         self._driver_status: str | None = None
         self._spark_exit_code: int | None = None
+        # Last few lines of the spark-submit process's own stdout/stderr, so 
failure
+        # exceptions can include the actual root cause instead of just an exit 
code.
+        self._last_submit_log_lines: deque[str] = deque(maxlen=20)
         self._env: dict[str, Any] | None = None
         self._post_submit_commands: list[str] = list(post_submit_commands) if 
post_submit_commands else []
         self._post_submit_commands_done: bool = False
@@ -530,6 +534,18 @@ class SparkSubmitHook(BaseHook, LoggingMixin):
 
         return connection_cmd_masked
 
+    @property
+    def _submit_log_tail(self) -> str:
+        """
+        The last few lines of the spark-submit process's own output.
+
+        Appended to submit-failure exceptions so the real root cause is 
visible instead of just an exit code.
+        """
+        if not self._last_submit_log_lines:
+            return ""
+        tail = "\n".join(self._mask_cmd([line]) for line in 
self._last_submit_log_lines)
+        return f"\nLast spark-submit output:\n{tail}"
+
     def _build_spark_common_args(self) -> list[str]:
         """
         Build common Spark arguments that are shared between spark-submit and 
spark-pipelines.
@@ -781,9 +797,11 @@ class SparkSubmitHook(BaseHook, LoggingMixin):
                     raise AirflowException(
                         f"Cannot execute: {self._mask_cmd(spark_submit_cmd)}. 
Error code is: {returncode}. "
                         f"Kubernetes spark exit code is: 
{self._spark_exit_code}"
+                        f"{self._submit_log_tail}"
                     )
                 raise AirflowException(
                     f"Cannot execute: {self._mask_cmd(spark_submit_cmd)}. 
Error code is: {returncode}."
+                    f"{self._submit_log_tail}"
                 )
 
             if self._should_track_yarn_application_via_rm_api():
@@ -794,6 +812,7 @@ class SparkSubmitHook(BaseHook, LoggingMixin):
             if self._should_track_driver_status and self._driver_id is None:
                 raise AirflowException(
                     "No driver id is known: something went wrong when 
executing the spark submit command"
+                    f"{self._submit_log_tail}"
                 )
         finally:
             # K8s-API tracking defers post-submit commands to 
_poll_k8s_driver_via_api's finally
@@ -866,6 +885,7 @@ class SparkSubmitHook(BaseHook, LoggingMixin):
                     self._driver_id = match_driver_id.group(0)
                     self.log.info("identified spark driver id: %s", 
self._driver_id)
 
+            self._last_submit_log_lines.append(line)
             self.log.info(line)
 
     def _start_yarn_application_status_tracking(self, application_id: str) -> 
None:
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 5733f44fd6b..45555a1186f 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
@@ -378,6 +378,33 @@ class TestSparkSubmitHook:
             bufsize=-1,
         )
 
+    @pytest.mark.db_test
+    
@patch("airflow.providers.apache.spark.hooks.spark_submit.subprocess.Popen")
+    def test_submit_failure_includes_captured_log_tail(self, mock_popen, 
sdk_connection_not_found):
+        mock_popen.return_value.stdout = StringIO(
+            "Exception in thread main: SparkException: bad jar\nsome other 
line"
+        )
+        mock_popen.return_value.stderr = StringIO("")
+        mock_popen.return_value.wait.return_value = 1
+
+        hook = SparkSubmitHook(conn_id="")
+
+        with pytest.raises(AirflowException, match="Last spark-submit 
output:") as exc_info:
+            hook.submit()
+        assert "Exception in thread main: SparkException: bad jar" in 
str(exc_info.value)
+
+    @pytest.mark.db_test
+    
@patch("airflow.providers.apache.spark.hooks.spark_submit.subprocess.Popen")
+    def test_submit_no_driver_id_includes_captured_log_tail(self, mock_popen, 
sdk_connection_not_found):
+        mock_popen.return_value.stdout = StringIO("some unrelated spark-submit 
output")
+        mock_popen.return_value.stderr = StringIO("")
+        mock_popen.return_value.wait.return_value = 0
+
+        hook = SparkSubmitHook(conn_id="spark_standalone_cluster")
+        with pytest.raises(AirflowException, match="No driver id is known") as 
exc_info:
+            hook.submit()
+        assert "Last spark-submit output:\nsome unrelated spark-submit output" 
in str(exc_info.value)
+
     @pytest.mark.db_test
     def test_resolve_should_track_driver_status(self, 
sdk_connection_not_found):
         # Given
@@ -986,6 +1013,24 @@ class TestSparkSubmitHook:
 
         assert hook._driver_id == "driver-20171128111415-0001"
 
+    def test_process_spark_submit_log_populates_last_submit_log_lines(self):
+        hook = SparkSubmitHook(conn_id="spark_standalone_cluster")
+        log_lines = [
+            "Running Spark using the REST application submission protocol.",
+            "17/11/28 11:14:15 INFO RestSubmissionClient: Submitting a request 
"
+            "to launch an application in spark://spark-standalone-master:6066",
+        ]
+
+        hook._process_spark_submit_log(log_lines)
+
+        assert list(hook._last_submit_log_lines) == log_lines
+
+    def 
test_process_spark_submit_log_last_submit_log_lines_truncates_to_maxlen(self):
+        hook = SparkSubmitHook(conn_id="spark_standalone_cluster")
+        log_lines = [f"line {i}" for i in range(25)]
+        hook._process_spark_submit_log(log_lines)
+        assert list(hook._last_submit_log_lines) == log_lines[-20:]
+
     def test_process_spark_driver_status_log(self):
         # Given
         hook = SparkSubmitHook(conn_id="spark_standalone_cluster")
@@ -1240,6 +1285,26 @@ class TestSparkSubmitHook:
         # Then
         assert command_masked == expected
 
+    @pytest.mark.db_test
+    def test_submit_log_tail_empty_when_no_lines_captured(self) -> None:
+        hook = SparkSubmitHook()
+
+        assert hook._submit_log_tail == ""
+
+    @pytest.mark.db_test
+    def test_submit_log_tail_formats_and_masks_captured_lines(self) -> None:
+        hook = SparkSubmitHook()
+        hook._last_submit_log_lines.append("Exception in thread main: 
SparkException: bad jar")
+        hook._last_submit_log_lines.append("--password='secret'")
+
+        tail = hook._submit_log_tail
+
+        assert tail == (
+            "\nLast spark-submit output:\n"
+            "Exception in thread main: SparkException: bad jar\n"
+            "--password='******'"
+        )
+
     @pytest.mark.db_test
     def test_create_keytab_path_from_base64_keytab_with_decode_exception(self):
         hook = SparkSubmitHook()

Reply via email to