You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@spark.apache.org by ch...@apache.org on 2026/08/17 02:12:34 UTC

(spark) branch branch-4.x updated: [SPARK-58619][CONNECT][FOLLOWUP] Map JDBC connection errors by SQLSTATE class 08 instead of hard-coded condition names

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

pan3793 pushed a commit to branch branch-4.x
in repository https://gitbox.apache.org/repos/asf/spark.git


The following commit(s) were added to refs/heads/branch-4.x by this push:
     new 7b879a36a46c [SPARK-58619][CONNECT][FOLLOWUP] Map JDBC connection errors by SQLSTATE class 08 instead of hard-coded condition names
7b879a36a46c is described below

commit 7b879a36a46c992e24509a99f30d834160875566
Author: Jiwon Park <jp...@outlook.kr>
AuthorDate: Mon Aug 17 10:12:08 2026 +0800

    [SPARK-58619][CONNECT][FOLLOWUP] Map JDBC connection errors by SQLSTATE class 08 instead of hard-coded condition names
    
    ### What changes were proposed in this pull request?
    
    Follow-up of SPARK-58619, requested in https://github.com/apache/spark/pull/57009#discussion_r3786757968: remove the JDBC driver's hard-coded `INVALID_HANDLE.SESSION_` remapping and key the `SQLNonTransientConnectionException` mapping on the SQLSTATE the converted error already carries (class `08`), keeping the server-provided state. The test stub now takes the sqlState explicitly, matching what `GrpcExceptionConverter` supplies.
    
    ### Why are the changes needed?
    
    Since SPARK-58619 the server reports `08003` for the `INVALID_HANDLE.SESSION_*` conditions, so the driver-side remapping duplicates knowledge in `error-conditions.json` and needs manual sync whenever conditions move within class `08` (raised in the SPARK-58619 review).
    
    ### Does this PR introduce _any_ user-facing change?
    
    No. Spark Connect requires the client version <= the server version, and against a same-or-newer server the same errors map to the same exception type and SQLSTATE (the driver first ships in 4.3). Any other class-08 SQLSTATE a server reports also maps to the connection-exception type, matching how JDBC consumers read class `08`.
    
    ### How was this patch tested?
    
    `JdbcErrorUtilsSuite`: a new case pins that a class-08 state maps to `SQLNonTransientConnectionException` regardless of the condition name (fails without the change); the existing gRPC, precedence, cause-chain, and passthrough cases are unchanged.
    
    ### Was this patch authored or co-authored using generative AI tooling?
    
    Generated-by: Claude Code (claude-fable-5)
    
    Closes #58022 from j1wonpark/SPARK-58619-followup-jdbc-sqlstate.
    
    Authored-by: Jiwon Park <jp...@outlook.kr>
    Signed-off-by: Cheng Pan <ch...@apache.org>
    (cherry picked from commit f76717f84791e03f7924f0000decd99aa352cfe5)
    Signed-off-by: Cheng Pan <ch...@apache.org>
---
 .../connect/client/jdbc/util/JdbcErrorUtils.scala  | 23 +++++++-------
 .../client/jdbc/util/JdbcErrorUtilsSuite.scala     | 35 ++++++++++++----------
 2 files changed, 29 insertions(+), 29 deletions(-)

diff --git a/sql/connect/client/jdbc/src/main/scala/org/apache/spark/sql/connect/client/jdbc/util/JdbcErrorUtils.scala b/sql/connect/client/jdbc/src/main/scala/org/apache/spark/sql/connect/client/jdbc/util/JdbcErrorUtils.scala
index 72d007c1304a..35236b32e220 100644
--- a/sql/connect/client/jdbc/src/main/scala/org/apache/spark/sql/connect/client/jdbc/util/JdbcErrorUtils.scala
+++ b/sql/connect/client/jdbc/src/main/scala/org/apache/spark/sql/connect/client/jdbc/util/JdbcErrorUtils.scala
@@ -62,23 +62,20 @@ private[jdbc] object JdbcErrorUtils {
 
   // SQLState class 08 is "connection exception"; HYT00 is the conventional
   // (ODBC-derived) state for an elapsed timeout.
-  private val CONNECTION_DOES_NOT_EXIST = "08003"
+  private val CONNECTION_EXCEPTION_ERROR_CLASS = "08"
   private val CONNECTION_FAILURE = "08006"
   private val TIMEOUT_EXPIRED = "HYT00"
 
-  private val SESSION_GONE_CONDITION_PREFIX = "INVALID_HANDLE.SESSION_"
-
   /**
    * Maps the unchecked exceptions raised by the Spark Connect client (a
    * [[SparkThrowable]] once GrpcExceptionConverter has converted the gRPC error) to
    * the [[SQLException]] a JDBC method is required to throw:
    *
-   *  - `INVALID_HANDLE.SESSION_*` means the server-side session is gone (e.g. it
-   *    timed out); the connection is unusable and retrying on it is pointless, so it
-   *    maps to a [[SQLNonTransientConnectionException]] with SQLState 08003
-   *    ("connection does not exist"). The operation-level subconditions
-   *    (`OPERATION_*`, `FORMAT`) concern a single operation on a healthy session
-   *    and are deliberately not treated as connection errors.
+   *  - a server error in SQLState class 08 ("connection exception", e.g. the
+   *    `INVALID_HANDLE.SESSION_*` conditions with 08003) means the session backing
+   *    the connection is gone; the connection is unusable and retrying on it is
+   *    pointless, so it maps to a [[SQLNonTransientConnectionException]] keeping
+   *    the server-provided SQLState.
    *  - a gRPC UNAVAILABLE (e.g. a server restart or a network blip) maps to a
    *    [[SQLTransientConnectionException]] with SQLState 08006 ("connection
    *    failure"), since a fresh connection can succeed.
@@ -98,18 +95,18 @@ private[jdbc] object JdbcErrorUtils {
     case e =>
       val chain = causeChain(e)
       val sparkThrowableOpt = chain.collectFirst { case st: SparkThrowable => st }
-      val condition = sparkThrowableOpt.flatMap(st => Option(st.getCondition))
+      val sqlState = sparkThrowableOpt.flatMap(st => Option(st.getSqlState))
       val grpcCode = chain.collectFirst { case sre: StatusRuntimeException =>
         sre.getStatus.getCode
       }
-      if (condition.exists(_.startsWith(SESSION_GONE_CONDITION_PREFIX))) {
-        new SQLNonTransientConnectionException(e.getMessage, CONNECTION_DOES_NOT_EXIST, e)
+      if (sqlState.exists(_.startsWith(CONNECTION_EXCEPTION_ERROR_CLASS))) {
+        new SQLNonTransientConnectionException(e.getMessage, sqlState.get, e)
       } else if (grpcCode.contains(Status.Code.UNAVAILABLE)) {
         new SQLTransientConnectionException(e.getMessage, CONNECTION_FAILURE, e)
       } else if (grpcCode.contains(Status.Code.DEADLINE_EXCEEDED)) {
         new SQLTimeoutException(e.getMessage, TIMEOUT_EXPIRED, e)
       } else {
-        new SQLException(e.getMessage, sparkThrowableOpt.map(_.getSqlState).orNull, e)
+        new SQLException(e.getMessage, sqlState.orNull, e)
       }
   }
 
diff --git a/sql/connect/client/jdbc/src/test/scala/org/apache/spark/sql/connect/client/jdbc/util/JdbcErrorUtilsSuite.scala b/sql/connect/client/jdbc/src/test/scala/org/apache/spark/sql/connect/client/jdbc/util/JdbcErrorUtilsSuite.scala
index fd434dcbb206..e64dd1f8681e 100644
--- a/sql/connect/client/jdbc/src/test/scala/org/apache/spark/sql/connect/client/jdbc/util/JdbcErrorUtilsSuite.scala
+++ b/sql/connect/client/jdbc/src/test/scala/org/apache/spark/sql/connect/client/jdbc/util/JdbcErrorUtilsSuite.scala
@@ -26,38 +26,37 @@ import org.apache.spark.sql.connect.test.ConnectFunSuite
 
 /**
  * Tests for [[JdbcErrorUtils.toSQLException]]. A RuntimeException mixing in
- * [[SparkThrowable]] stands in for converted server errors; a real
- * [[StatusRuntimeException]] cause models transport errors the way
- * GrpcExceptionConverter preserves them.
+ * [[SparkThrowable]] stands in for converted server errors, carrying the sqlState
+ * GrpcExceptionConverter would supply. A real [[StatusRuntimeException]] cause
+ * models transport errors the way GrpcExceptionConverter preserves them.
  */
 class JdbcErrorUtilsSuite extends ConnectFunSuite {
 
   private def sparkError(
       condition: String,
+      sqlState: String,
       msg: String,
       cause: Throwable = null): RuntimeException =
     new RuntimeException(msg, cause) with SparkThrowable {
       override def getCondition: String = condition
+      override def getSqlState: String = sqlState
     }
 
   private def grpcError(status: Status): StatusRuntimeException =
     new StatusRuntimeException(status)
 
-  test("INVALID_HANDLE.SESSION_CLOSED maps to a connection exception with SQLState 08003") {
+  test("a class-08 SQLSTATE maps to a non-transient connection exception") {
     val e = JdbcErrorUtils.toSQLException(
-      sparkError("INVALID_HANDLE.SESSION_CLOSED", "Session was closed"))
+      sparkError("INVALID_HANDLE.SESSION_CLOSED", "08003", "Session was closed"))
     assert(e.isInstanceOf[SQLNonTransientConnectionException])
     assert(e.getSQLState === "08003")
     assert(e.getMessage === "Session was closed")
   }
 
-  test("other session-level INVALID_HANDLE subconditions also map to 08003") {
-    Seq("INVALID_HANDLE.SESSION_NOT_FOUND", "INVALID_HANDLE.SESSION_CHANGED").foreach {
-      condition =>
-        val e = JdbcErrorUtils.toSQLException(sparkError(condition, "gone"))
-        assert(e.isInstanceOf[SQLNonTransientConnectionException], condition)
-        assert(e.getSQLState === "08003", condition)
-    }
+  test("the class-08 mapping does not depend on the condition name") {
+    val e = JdbcErrorUtils.toSQLException(sparkError("SOME_FUTURE_CONDITION", "08004", "gone"))
+    assert(e.isInstanceOf[SQLNonTransientConnectionException])
+    assert(e.getSQLState === "08004")
   }
 
   test("operation-level INVALID_HANDLE subconditions do not map to a connection exception") {
@@ -67,14 +66,14 @@ class JdbcErrorUtilsSuite extends ConnectFunSuite {
       "INVALID_HANDLE.OPERATION_ABANDONED",
       "INVALID_HANDLE.OPERATION_ALREADY_EXISTS",
       "INVALID_HANDLE.FORMAT").foreach { condition =>
-      val e = JdbcErrorUtils.toSQLException(sparkError(condition, "operation gone"))
+      val e = JdbcErrorUtils.toSQLException(sparkError(condition, "HY000", "operation gone"))
       assert(!e.isInstanceOf[SQLNonTransientConnectionException], condition)
       assert(!e.isInstanceOf[SQLTransientConnectionException], condition)
     }
   }
 
   test("a non-connection Spark error keeps the server-provided SQLState") {
-    val e = JdbcErrorUtils.toSQLException(sparkError("DIVIDE_BY_ZERO", "boom"))
+    val e = JdbcErrorUtils.toSQLException(sparkError("DIVIDE_BY_ZERO", "22012", "boom"))
     assert(!e.isInstanceOf[SQLNonTransientConnectionException])
     assert(!e.isInstanceOf[SQLTransientConnectionException])
     assert(e.getSQLState === "22012")
@@ -92,6 +91,7 @@ class JdbcErrorUtilsSuite extends ConnectFunSuite {
     // the shape GrpcExceptionConverter produces for transport errors
     val wrapped = sparkError(
       "CONNECT_CLIENT_UNEXPECTED_MISSING_SQL_STATE",
+      "XXKCM",
       "io.grpc.StatusRuntimeException: UNAVAILABLE: io exception",
       cause = grpcError(Status.UNAVAILABLE.withDescription("io exception")))
     val e = JdbcErrorUtils.toSQLException(wrapped)
@@ -104,6 +104,7 @@ class JdbcErrorUtilsSuite extends ConnectFunSuite {
     // server error embedding gRPC text (e.g. from a UDF) is not a transport failure
     val e = JdbcErrorUtils.toSQLException(sparkError(
       "FAILED_EXECUTE_UDF",
+      "39000",
       "Job aborted: io.grpc.StatusRuntimeException: UNAVAILABLE: backend down"))
     assert(!e.isInstanceOf[SQLTransientConnectionException])
     assert(!e.isInstanceOf[SQLNonTransientConnectionException])
@@ -113,6 +114,7 @@ class JdbcErrorUtilsSuite extends ConnectFunSuite {
     // a slow query fires the RPC deadline on a healthy connection: timeout, not class 08
     val wrapped = sparkError(
       "CONNECT_CLIENT_UNEXPECTED_MISSING_SQL_STATE",
+      "XXKCM",
       "io.grpc.StatusRuntimeException: DEADLINE_EXCEEDED: deadline exceeded",
       cause = grpcError(Status.DEADLINE_EXCEEDED.withDescription("deadline exceeded")))
     val e = JdbcErrorUtils.toSQLException(wrapped)
@@ -129,10 +131,11 @@ class JdbcErrorUtilsSuite extends ConnectFunSuite {
     assert(!e.isInstanceOf[SQLTimeoutException])
   }
 
-  test("a closed session takes precedence over a transport code") {
+  test("a class-08 error takes precedence over a transport code") {
     // both signals present: the gone session wins
     val e = JdbcErrorUtils.toSQLException(sparkError(
       "INVALID_HANDLE.SESSION_CLOSED",
+      "08003",
       "closed",
       cause = grpcError(Status.UNAVAILABLE.withDescription("x"))))
     assert(e.isInstanceOf[SQLNonTransientConnectionException])
@@ -140,7 +143,7 @@ class JdbcErrorUtilsSuite extends ConnectFunSuite {
   }
 
   test("the connection error is found through the cause chain") {
-    val root = sparkError("INVALID_HANDLE.SESSION_CLOSED", "closed")
+    val root = sparkError("INVALID_HANDLE.SESSION_CLOSED", "08003", "closed")
     val e = JdbcErrorUtils.toSQLException(new RuntimeException("wrapper", root))
     assert(e.isInstanceOf[SQLNonTransientConnectionException])
     assert(e.getSQLState === "08003")


---------------------------------------------------------------------
To unsubscribe, e-mail: commits-unsubscribe@spark.apache.org
For additional commands, e-mail: commits-help@spark.apache.org