spark-test commented on code in PR #59962:
URL: https://github.com/apache/spark/pull/59962#discussion_r4429756810


##########
sql/connect/server/src/main/scala/org/apache/spark/sql/connect/utils/ErrorUtils.scala:
##########
@@ -46,6 +46,21 @@ import org.apache.spark.util.Utils
 
 private[connect] object ErrorUtils extends Logging {
 
+  private def compatibleErrorClass(error: SparkThrowable): (String, 
Map[String, String]) = {
+    val parameters = Option(error.getMessageParameters)
+      .map(_.asScala.toMap)
+      .getOrElse(Map.empty[String, String])
+    if (error.getCondition == "FAILED_JDBC.WRITE") {
+      // Connect clients have no negotiated error-catalog capability. Keep the 
URL supplied
+      // by the WRITE error; it has already been redacted by JDBCOptions.
+      ("FAILED_JDBC.UNCLASSIFIED", Map(

Review Comment:
   **Nit (P3):** This rewrites the error class and parameters but not the 
message. The `FetchErrorDetails` message (`currentError.getMessage`, line 103) 
and the gRPC status description (`errorDescription(st)`, line 296) are still 
rendered from the original exception, so both read `[FAILED_JDBC.WRITE] Failed 
JDBC <url> on the operation: Write rows to the table <t>. SQLSTATE: HV000`.
   
   The Python Connect client uses that server message verbatim 
(`python/pyspark/errors/exceptions/connect.py`, lines 187-196) but takes the 
condition from the projected metadata. So for any write failure classified as 
`FAILED_JDBC.WRITE`, a PySpark Connect user sees `[FAILED_JDBC.WRITE]` in 
`str(e)` while `e.getCondition()` returns `FAILED_JDBC.UNCLASSIFIED`. A handler 
written from the printed text never matches. The Scala Connect client 
re-renders the message from the projected class and prints 
`[FAILED_JDBC.UNCLASSIFIED]`, so the two clients disagree about the same 
failure. Classic sessions are not affected.
   
   As long as the projection exists, it could also return the message rendered 
for `FAILED_JDBC.UNCLASSIFIED` from the projected parameters (for example with 
`SparkThrowableHelper.getMessage`) and use it on both channels. The new 
`ErrorUtilsSuite` case checks the class, SQLSTATE and parameters but neither 
message, so it does not catch this.
   
   **Verification:**
   - **Behavior:** When the server converts a FAILED_JDBC.WRITE error, both the 
gRPC status description and the root error-details message name 
FAILED_JDBC.UNCLASSIFIED and contain the redacted URL and the table name, 
matching the projected error class and parameters.
   - **Compatibility:** An error with any other condition keeps the same status 
description and error-details message as before the change, and its cause chain 
is serialized unchanged.
   - **Regression:** For a failed JDBC write, a Connect client that builds the 
exception from the server-rendered message, as the Python client does, reports 
the same condition in its printed message and in getCondition().



##########
sql/core/src/test/scala/org/apache/spark/sql/execution/datasources/jdbc/JdbcTaskInterruptSuite.scala:
##########
@@ -57,6 +57,252 @@ class JdbcTaskInterruptSuite extends SharedSparkSession {
     JdbcDialects.registerDialect(dialect)
   }
 
+  private def savePartitionWithSourceError(iterator: Iterator[Row]): 
SQLException = {
+    val conn = mock(classOf[Connection])
+    val stmt = mock(classOf[PreparedStatement])
+    when(conn.prepareStatement(anyString())).thenReturn(stmt)
+
+    val dialect = new JdbcDialect with NoLegacyJDBCError {
+      override def canHandle(url: String): Boolean = url == testJdbcUrl
+      override def createConnectionFactory(options: JDBCOptions): Int => 
Connection = _ => conn
+    }
+    registerDialect(dialect)
+    TaskContext.setTaskContext(TaskContext.empty())
+    try {
+      intercept[SQLException] {
+        JdbcUtils.savePartition(
+          "t",
+          iterator,
+          StructType(Seq(StructField("a", IntegerType))),
+          "INSERT INTO t (a) VALUES (?)",
+          batchSize = 1,
+          dialect,
+          Connection.TRANSACTION_NONE,
+          newWriteOptions())
+      }
+    } finally {
+      TaskContext.unset()
+      JdbcDialects.unregisterDialect(dialect)
+    }
+  }
+
+  test("savePartition does not classify a later source hasNext failure as a 
JDBC write") {
+    val sourceFailure = new SQLException("source read failed", "08006")
+    val nextFailure = new SQLException("source connection detail", "08001")
+    sourceFailure.setNextException(nextFailure)
+    val iterator = new Iterator[Row] {
+      private var readFirstRow = false
+      override def hasNext: Boolean = {
+        if (readFirstRow) throw sourceFailure
+        true
+      }
+      override def next(): Row = {
+        readFirstRow = true
+        Row(1)
+      }
+    }
+
+    assert(savePartitionWithSourceError(iterator) eq sourceFailure)
+    assert(sourceFailure.getSQLState == "08006")
+    assert(sourceFailure.getNextException eq nextFailure)
+    assert(sourceFailure.getCause eq nextFailure)
+  }
+
+  test("savePartition preserves a classified later source next failure") {

Review Comment:
   **Nit (P3):** This is the only test of a failure in the source iterator's 
`next()`, and it throws a `SparkSQLException`. `classifyWriteException` would 
rethrow that exception unchanged anyway, through its `SparkThrowable` bypass, 
and it has no next exception to link. So this test still passes if `next()` 
moves into `withWriteException` or the binding `try`, or if `sourceNext` stops 
calling `linkNextException`. In the first case, a failing upstream read in 
`next()` would be reported as `FAILED_JDBC.WRITE` on the destination table. A 
plain `SQLException` with a chained next exception, like the one the `hasNext` 
test above uses, would make this path regression-sensitive.
   
   **Verification:**
   - **Regression:** When the source iterator's next() throws a plain 
SQLException with a chained next exception during a JDBC write, the same 
exception propagates unclassified, keeps its original SQLSTATE, and has the 
next exception as its cause. The check fails if next() moves into a classified 
region or the linking is removed.



##########
sql/core/src/main/scala/org/apache/spark/sql/execution/datasources/jdbc/JdbcUtils.scala:
##########
@@ -718,75 +718,127 @@ object JdbcUtils extends Logging with SQLConfHelper {
     }
     val supportsTransactions = finalIsolationLevel != 
Connection.TRANSACTION_NONE
     var totalRowCount = 0L
+    def linkNextException(e: SQLException): Unit = {
+      val cause = e.getNextException
+      if (cause != null && e.getCause != cause) {
+        // If there is no cause already, set 'next exception' as cause. If 
cause is null,
+        // it *may* be because no cause was set yet
+        if (e.getCause == null) {
+          try {
+            e.initCause(cause)
+          } catch {
+            // Or it may be null because the cause *was* explicitly 
initialized, to *null*,
+            // in which case this fails. There is no other way to detect it.
+            // addSuppressed in this case as well.
+            case _: IllegalStateException => e.addSuppressed(cause)
+          }
+        } else {
+          e.addSuppressed(cause)
+        }
+      }
+    }
+
+    def sourceHasNext: Boolean = {
+      try {
+        iterator.hasNext
+      } catch {
+        case e: SQLException =>
+          linkNextException(e)
+          throw e
+      }
+    }
+
+    def sourceNext(): Row = {
+      try {
+        iterator.next()
+      } catch {
+        case e: SQLException =>
+          linkNextException(e)
+          throw e
+      }
+    }
+
+    def classifyWriteException(e: SQLException): Nothing = {
+      linkNextException(e)
+      // Closing the connection on task cancellation can cause a SQLException. 
Do not
+      // report that cancellation side effect as a JDBC write failure.
+      if (TaskContext.get().isInterrupted() || e.isInstanceOf[SparkThrowable]) 
{
+        throw e
+      }
+      throw dialect.classifyException(
+        e,
+        condition = "FAILED_JDBC.WRITE",
+        messageParameters = Map("url" -> options.getRedactUrl(), "tableName" 
-> table),
+        description = "Write rows to the table.",

Review Comment:
   **Nit (P3):** Dialects that don't override the condition-based 
`classifyException` fall back to the deprecated `classifyException(message, 
e)`. On that path this description is the entire error message, and the URL is 
fixed to `jdbc:`. That includes `NoopDialect`, which is used for any URL 
without a registered dialect (SQLite, for example), and third-party dialects 
that keep the default. Their write failures surface as 
`[FAILED_JDBC.UNCLASSIFIED] Failed JDBC jdbc: on the operation: Write rows to 
the table. SQLSTATE: HV000`, which names neither the database nor the table. 
When a job writes several JDBC tables, the failing one can then only be found 
through the cause or the stack trace. The `FAILED_JDBC.WRITE` rendering and the 
Connect projection both name the table.
   
   Built-in dialects ignore this description, so naming the table here only 
changes the legacy path. The expected message in the legacy-dialect test would 
change with it:
   
   ```suggestion
           description = s"Write rows to the table $table.",
   ```
   
   **Verification:**
   - **Regression:** Classifying a write failure through a dialect that 
inherits the default classifyException yields FAILED_JDBC.UNCLASSIFIED, and its 
message parameter names the target table.
   - **Compatibility:** For dialects with condition-based classification, a 
write failure still yields FAILED_JDBC.WRITE whose only parameters are the 
redacted URL and the table name.



##########
sql/core/src/test/scala/org/apache/spark/sql/execution/datasources/jdbc/JdbcTaskInterruptSuite.scala:
##########
@@ -57,6 +57,252 @@ class JdbcTaskInterruptSuite extends SharedSparkSession {
     JdbcDialects.registerDialect(dialect)
   }
 
+  private def savePartitionWithSourceError(iterator: Iterator[Row]): 
SQLException = {
+    val conn = mock(classOf[Connection])
+    val stmt = mock(classOf[PreparedStatement])
+    when(conn.prepareStatement(anyString())).thenReturn(stmt)
+
+    val dialect = new JdbcDialect with NoLegacyJDBCError {
+      override def canHandle(url: String): Boolean = url == testJdbcUrl
+      override def createConnectionFactory(options: JDBCOptions): Int => 
Connection = _ => conn
+    }
+    registerDialect(dialect)
+    TaskContext.setTaskContext(TaskContext.empty())
+    try {
+      intercept[SQLException] {
+        JdbcUtils.savePartition(
+          "t",
+          iterator,
+          StructType(Seq(StructField("a", IntegerType))),
+          "INSERT INTO t (a) VALUES (?)",
+          batchSize = 1,
+          dialect,
+          Connection.TRANSACTION_NONE,
+          newWriteOptions())
+      }
+    } finally {
+      TaskContext.unset()
+      JdbcDialects.unregisterDialect(dialect)
+    }
+  }
+
+  test("savePartition does not classify a later source hasNext failure as a 
JDBC write") {

Review Comment:
   **Nit (P3):** This test and the five after it cover `savePartition` 
write-error classification and source-failure propagation, all under a 
non-interrupted `TaskContext`. The suite's name and class ScalaDoc (lines 
36-42) still scope it to JDBC task interrupt handling (SPARK-56019), and after 
this PR six of its nine tests fall outside that scope. The ScalaDoc is now 
wrong for most of the suite, and someone looking for the new 
`FAILED_JDBC.WRITE` coverage is unlikely to look here. Moving these tests, with 
the helpers they need, into a suite whose name and ScalaDoc describe 
write-error classification would keep both suites accurate.
   
   **Verification:**
   - **Inspection:** Every test in the interrupt-handling suite exercises task 
interruption, and every write-classification or source-propagation test lives 
in a suite whose name and ScalaDoc describe JDBC write-error classification.
   - **Behavior:** After the move, the relocated tests still run and keep all 
of their original assertions on condition, SQLSTATE, parameters, cause identity 
and rollback.



-- 
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.

To unsubscribe, e-mail: [email protected]

For queries about this service, please contact Infrastructure at:
[email protected]


---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]

Reply via email to