zifeif2 opened a new pull request, #59449:
URL: https://github.com/apache/spark/pull/59449

   ### What changes were proposed in this pull request?
   
   Add regression tests that reproduce Scala Spark Connect losing structured 
server errors whose conditions are absent from the client's core error catalog. 
This PR changes only `SparkConnectClientSuite`; it contains no production fix.
   
   The tests cover unknown conditions and subclasses across all 18 mapped 
exception types, enriched Kafka errors with causes/query contexts/server stack 
traces, and the ErrorInfo fallback with truncated parameters. The existing 
known-condition test also checks the actual message and every mapped type.
   
   The implementation is reviewed separately in 
https://github.com/apache/spark/pull/59409.
   
   ### Why are the changes needed?
   
   The server sends its rendered message, but the Scala converter reconstructs 
some exception types using constructors that render the condition again with 
the client's core catalog. Connector-specific conditions and conditions from 
newer servers can be absent there. Reconstruction then throws `INTERNAL_ERROR`, 
hiding the original failure.
   
   The regression tests intentionally assert the desired behavior and fail on 
unfixed Spark. This reproduction PR stays draft while the implementation is 
reviewed separately. Merge the implementation before these tests.
   
   ### Does this PR introduce _any_ user-facing change?
   
   No. This PR adds tests only.
   
   ### How was this patch tested?
   
   Compiled the seven affected production files from current master and from 
the fix, plus this test suite, using Scala 2.13.18, Java 17, and cached 
dependencies. Ran the 10 tests matching `error` through ScalaTest:
   
   - Unfixed code: 7 passed, 3 failed. All three new regression tests fail 
because client reconstruction throws `INTERNAL_ERROR` for an unknown condition.
   - Fixed code from #59409: all 10 passed.
   - `git diff --check` passed.
   
   The normal SBT invocation could not load the project because Maven Central 
was unreachable from the local environment; the direct compiler/ScalaTest runs 
above used the existing cached test classpath. No source changes were made to 
work around dependency resolution.
   
   To run the focused tests through the normal build:
   
   ```bash
   build/sbt 'connect-client-jvm/testOnly *SparkConnectClientSuite -- -z error'
   ```
   
   The released Spark 4.1.1 reproduction below also demonstrates the existing 
issue with real Kafka option validation. It does not require #59219 or a Kafka 
broker. The recorded released-version runs are separate from the 
regression-test validation above.
   
   #### Released Spark 4.1.1 reproduction
   
   This is reproducible with existing Kafka conditions in released Spark 4.1.1,
   without applying #59219. Both the Connect client/server and the classic 
comparison
   used Spark 4.1.1, the Kafka 4.1.1 connector, Java 17, and Scala 2.13.17.
   
   The Connect server ran with `local[2]` on a Linux development machine. A 
separate
   Scala client JVM connected over loopback gRPC. Classic Scala ran in its own 
local
   Spark driver. No Databricks workspace or remote cluster was used. No Kafka 
broker,
   topic, or data is required: these invalid offset options fail validation 
before
   broker access.
   
   Two existing conditions were reproduced:
   
   | Input | Existing Kafka condition |
   | --- | --- |
   | Start at offset 10 and end at offset 5 for `repro_topic`, partition 0 | 
`UNRESOLVED_START_OFFSET_GREATER_THAN_END_OFFSET` |
   | Start offsets name partition 0; end offsets name partition 1 | 
`MISMATCHED_TOPIC_PARTITIONS_BETWEEN_START_OFFSET_AND_END_OFFSET` |
   
   For both inputs:
   
   | Where the error was observed | Result |
   | --- | --- |
   | Connect server log | `KafkaIllegalArgumentException` with the useful Kafka 
explanation |
   | Scala Connect client | `SparkException`, condition `INTERNAL_ERROR`, 
SQLSTATE `XX000`; original explanation lost |
   | Classic Scala | `KafkaIllegalArgumentException`, original Kafka condition 
and explanation preserved |
   
   Recorded Scala Connect output:
   
   ```text
   Client: released Scala Spark Connect 4.1.1
   Server Spark version: 4.1.1
   Session implementation: org.apache.spark.sql.connect.SparkSession
   
   Actual client exception: org.apache.spark.SparkException
   Actual client condition: INTERNAL_ERROR
   Actual client message: [INTERNAL_ERROR] Cannot find main error class 
'UNRESOLVED_START_OFFSET_GREATER_THAN_END_OFFSET' SQLSTATE: XX000
   
   Actual client exception: org.apache.spark.SparkException
   Actual client condition: INTERNAL_ERROR
   Actual client message: [INTERNAL_ERROR] Cannot find main error class 
'MISMATCHED_TOPIC_PARTITIONS_BETWEEN_START_OFFSET_AND_END_OFFSET' SQLSTATE: 
XX000
   ```
   
   The client stack goes through `SparkThrowableHelper.getMessage`,
   `GrpcExceptionConverter.errorFactory`, `errorsToThrowable`, and 
`fetchEnrichedError`.
   The server log contains the original explanations:
   
   ```text
   org.apache.spark.sql.kafka010.KafkaIllegalArgumentException: The specified 
start offset 10 is greater than the end offset 5 for topic repro_topic 
partition 0.
   org.apache.spark.sql.kafka010.KafkaIllegalArgumentException: The specified 
start offset and end offset should have the same topic-partitions.
   ```
   
   Recorded classic Scala output:
   
   ```text
   Spark version: 4.1.1
   Session implementation: org.apache.spark.sql.classic.SparkSession
   SparkContext master: local[2]
   
   Actual exception: org.apache.spark.sql.kafka010.KafkaIllegalArgumentException
   Actual condition: UNRESOLVED_START_OFFSET_GREATER_THAN_END_OFFSET
   Actual message: The specified start offset 10 is greater than the end offset 
5 for topic repro_topic partition 0.
   
   Actual exception: org.apache.spark.sql.kafka010.KafkaIllegalArgumentException
   Actual condition: 
MISMATCHED_TOPIC_PARTITIONS_BETWEEN_START_OFFSET_AND_END_OFFSET
   Actual message: The specified start offset and end offset should have the 
same topic-partitions.
   Topic-partitions for start offset: repro_topic-0, topic-partitions for end 
offset: repro_topic-1.
   ```
   
   These recorded runs demonstrate the bug in the released binaries. The focused
   tests listed above validate this PR's fix; the released-version comparison is
   not an end-to-end test of a patched client binary. The standalone Scala 
sources
   below also compiled successfully against the released 4.1.1 dependencies.
   
   <details>
   <summary>Standalone Scala reproduction sources</summary>
   
   Save these two files together in a working directory. The Connect program 
asserts
   the existing bug; the classic program asserts that both original errors 
survive.
   
   ##### ScalaConnectKafkaErrorRepro.scala
   
   ```scala
   import org.apache.spark.SparkThrowable
   import org.apache.spark.sql.connect.SparkSession
   import scala.util.control.NonFatal
   
   /**
    * Existing Kafka errors masked by Scala Spark Connect 4.1.1.
    * Run with the released spark-connect-client-jvm_2.13:4.1.1 client in a 
separate JVM.
    * The Connect server needs the matching Kafka connector. No Kafka broker is 
required.
    */
   object ScalaConnectKafkaErrorRepro {
     case class ReproCase(
         condition: String,
         startOffsets: String,
         endOffsets: String,
         expectedMessage: String)
   
     val cases = Seq(
       ReproCase(
         "UNRESOLVED_START_OFFSET_GREATER_THAN_END_OFFSET",
         """{"repro_topic":{"0":10}}""",
         """{"repro_topic":{"0":5}}""",
         "The specified start offset 10 is greater than the end offset 5 " +
           "for topic repro_topic partition 0."),
       ReproCase(
         "MISMATCHED_TOPIC_PARTITIONS_BETWEEN_START_OFFSET_AND_END_OFFSET",
         """{"repro_topic":{"0":10}}""",
         """{"repro_topic":{"1":5}}""",
         "The specified start offset and end offset should have the same 
topic-partitions."))
   
     def main(args: Array[String]): Unit = {
       require(args.length == 1, "Usage: ScalaConnectKafkaErrorRepro 
sc://localhost:15002")
       val spark = SparkSession.builder().remote(args(0)).getOrCreate()
       try {
         println("Client: released Scala Spark Connect 4.1.1")
         println(s"Server Spark version: ${spark.version}")
         println(s"Session implementation: ${spark.getClass.getName}")
   
         cases.foreach { testCase =>
           println(s"\nExisting Kafka condition: ${testCase.condition}")
           println(s"Expected Kafka message: ${testCase.expectedMessage}")
   
           val caught = try {
             spark.read
               .format("kafka")
               .option("kafka.bootstrap.servers", "127.0.0.1:1")
               .option("assign", """{"repro_topic":[0,1]}""")
               .option("startingOffsets", testCase.startOffsets)
               .option("endingOffsets", testCase.endOffsets)
               .option("kafka.request.timeout.ms", "1000")
               .option("kafka.default.api.timeout.ms", "1000")
               .load()
               .collect()
             None
           } catch {
             case NonFatal(error) => Some(error)
           }
   
           val error = caught.getOrElse(sys.error("Expected the invalid Kafka 
options to fail."))
           val condition = error match {
             case structured: SparkThrowable => structured.getCondition
             case _ => "<not a SparkThrowable>"
           }
           println(s"Actual client exception: ${error.getClass.getName}")
           println(s"Actual client condition: $condition")
           println(s"Actual client message: ${error.getMessage}")
           println("Relevant client stack frames:")
           error.getStackTrace.filter { frame =>
             frame.getClassName.contains("GrpcExceptionConverter") ||
             frame.getClassName.contains("ErrorClassesJSONReader") ||
             frame.getClassName.contains("SparkThrowableHelper")
           }.take(8).foreach(frame => println(s"  at $frame"))
   
           require(condition == "INTERNAL_ERROR", "Did not reproduce the 
original converter bug.")
           require(error.getMessage.contains("Cannot find main error class"))
           require(error.getMessage.contains(testCase.condition))
           
require(error.getStackTrace.exists(_.getClassName.contains("GrpcExceptionConverter")))
           println("REPRODUCED: Scala Connect replaced the Kafka error with 
INTERNAL_ERROR.")
         }
         println("\nBoth existing Kafka errors reproduced through real Scala 
Connect RPCs.")
       } finally {
         spark.stop()
       }
     }
   }
   ```
   
   ##### ScalaClassicKafkaErrorRepro.scala
   
   ```scala
   import org.apache.spark.SparkThrowable
   import org.apache.spark.sql.SparkSession
   import scala.util.control.NonFatal
   
   /**
    * Classic Scala counterpart of ScalaConnectKafkaErrorRepro.
    * Uses the same Kafka options and existing conditions on released Spark 
4.1.1.
    * The Kafka connector is required; no Kafka broker is needed.
    */
   object ScalaClassicKafkaErrorRepro {
     case class ReproCase(
         condition: String,
         startOffsets: String,
         endOffsets: String,
         expectedMessage: String)
   
     val cases = Seq(
       ReproCase(
         "UNRESOLVED_START_OFFSET_GREATER_THAN_END_OFFSET",
         """{"repro_topic":{"0":10}}""",
         """{"repro_topic":{"0":5}}""",
         "The specified start offset 10 is greater than the end offset 5 " +
           "for topic repro_topic partition 0."),
       ReproCase(
         "MISMATCHED_TOPIC_PARTITIONS_BETWEEN_START_OFFSET_AND_END_OFFSET",
         """{"repro_topic":{"0":10}}""",
         """{"repro_topic":{"1":5}}""",
         "The specified start offset and end offset should have the same 
topic-partitions."))
   
     // Notebook callers can reuse their existing classic session without 
stopping it.
     def verify(spark: SparkSession): Unit = {
       require(spark.isInstanceOf[org.apache.spark.sql.classic.SparkSession],
         "Use a classic Scala SparkSession for this reproduction.")
       println(s"Spark version: ${spark.version}")
       println(s"Session implementation: ${spark.getClass.getName}")
       println(s"SparkContext master: ${spark.sparkContext.master}")
   
       cases.foreach { testCase =>
         println(s"\nExisting Kafka condition: ${testCase.condition}")
         println(s"Expected Kafka message: ${testCase.expectedMessage}")
   
         val caught = try {
           spark.read
             .format("kafka")
             .option("kafka.bootstrap.servers", "127.0.0.1:1")
             .option("assign", """{"repro_topic":[0,1]}""")
             .option("startingOffsets", testCase.startOffsets)
             .option("endingOffsets", testCase.endOffsets)
             .option("kafka.request.timeout.ms", "1000")
             .option("kafka.default.api.timeout.ms", "1000")
             .load()
             .collect()
           None
         } catch {
           case NonFatal(error) => Some(error)
         }
   
         val error = caught.getOrElse(sys.error("Expected the invalid Kafka 
options to fail."))
         val condition = error match {
           case structured: SparkThrowable => structured.getCondition
           case _ => "<not a SparkThrowable>"
         }
         val message = error.getMessage
         println(s"Actual exception: ${error.getClass.getName}")
         println(s"Actual condition: $condition")
         println(s"Actual message: $message")
   
         require(error.isInstanceOf[IllegalArgumentException])
         require(condition == testCase.condition, "The original Kafka condition 
was not preserved.")
         require(message.contains(testCase.expectedMessage), "The Kafka 
explanation was lost.")
         require(!message.contains("INTERNAL_ERROR"))
         
require(!error.getStackTrace.exists(_.getClassName.contains("GrpcExceptionConverter")))
         println("PASS: classic Scala preserved the Kafka condition and 
message; no INTERNAL_ERROR.")
       }
       println("\nBoth existing Kafka errors remain actionable in classic Scala 
Spark.")
     }
   
     def main(args: Array[String]): Unit = {
       val spark = SparkSession.builder()
         .master("local[2]")
         .appName("classic-scala-kafka-error-repro")
         .config("spark.api.mode", "classic")
         .getOrCreate()
       try {
         require(spark.version == "4.1.1", "Use Spark 4.1.1 for the 
same-version comparison.")
         spark.sparkContext.setLogLevel("ERROR")
         verify(spark)
       } finally {
         spark.stop()
       }
     }
   }
   ```
   
   </details>
   
   <details>
   <summary>Build and run the standalone Scala reproductions</summary>
   
   Prerequisites: a Spark 4.1.1 binary distribution, Java 17 JDK, and Maven.
   Set `SPARK_HOME` to that distribution and `JAVA_HOME` to the JDK; make `mvn`
   available on `PATH`. Run these Bash commands in the directory containing the 
two
   Scala files. Maven resolves public artifacts from Maven Central (or your 
configured
   mirror). Python and Jupyter are not needed.
   
   **1. Resolve dependencies and build the two application JARs.**
   
   ```bash
   set -euo pipefail
   : "${SPARK_HOME:?Set SPARK_HOME to a Spark 4.1.1 binary distribution}"
   : "${JAVA_HOME:?Set JAVA_HOME to a Java 17 JDK}"
   unset SPARK_REMOTE SPARK_CONNECT_MODE_ENABLED SPARK_API_MODE
   export SPARK_HOME JAVA_HOME
   export SPARK_LOCAL_IP=127.0.0.1
   export KAFKA_REPRO_DIR="$PWD"
   
   mkdir -p client-deps kafka-deps connect-classes classic-classes
   
   cat > client-deps/pom.xml <<'XML'
   <project xmlns="http://maven.apache.org/POM/4.0.0";>
     <modelVersion>4.0.0</modelVersion>
     
<groupId>local.reproduction</groupId><artifactId>connect-client</artifactId><version>1</version>
     <dependencies><dependency>
       
<groupId>org.apache.spark</groupId><artifactId>spark-connect-client-jvm_2.13</artifactId>
       <version>4.1.1</version>
     </dependency></dependencies>
   </project>
   XML
   
   cat > kafka-deps/pom.xml <<'XML'
   <project xmlns="http://maven.apache.org/POM/4.0.0";>
     <modelVersion>4.0.0</modelVersion>
     
<groupId>local.reproduction</groupId><artifactId>kafka-connector</artifactId><version>1</version>
     <dependencies><dependency>
       
<groupId>org.apache.spark</groupId><artifactId>spark-sql-kafka-0-10_2.13</artifactId>
       <version>4.1.1</version>
     </dependency></dependencies>
   </project>
   XML
   
   mvn -B -q -f client-deps/pom.xml \
     org.apache.maven.plugins:maven-dependency-plugin:3.8.1:copy-dependencies \
     -DincludeScope=runtime 
-DoutputDirectory="$KAFKA_REPRO_DIR/client-deps/jars"
   
   mvn -B -q -f kafka-deps/pom.xml \
     org.apache.maven.plugins:maven-dependency-plugin:3.8.1:copy-dependencies \
     -DincludeScope=runtime \
     
-DincludeArtifactIds=spark-sql-kafka-0-10_2.13,spark-token-provider-kafka-0-10_2.13,kafka-clients,commons-pool2
 \
     -DoutputDirectory="$KAFKA_REPRO_DIR/kafka-deps/jars"
   
   "$JAVA_HOME/bin/java" -cp "$KAFKA_REPRO_DIR/client-deps/jars/*" \
     scala.tools.nsc.Main -usejavacp -d connect-classes 
ScalaConnectKafkaErrorRepro.scala
   "$JAVA_HOME/bin/jar" --create --file connect-repro.jar -C connect-classes .
   
   "$JAVA_HOME/bin/java" -cp "$SPARK_HOME/jars/*" \
     scala.tools.nsc.Main -usejavacp -d classic-classes 
ScalaClassicKafkaErrorRepro.scala
   "$JAVA_HOME/bin/jar" --create --file classic-repro.jar -C classic-classes .
   
   printf -v KAFKA_REPRO_JARS '%s,' "$KAFKA_REPRO_DIR"/kafka-deps/jars/*.jar
   export KAFKA_REPRO_JARS="${KAFKA_REPRO_JARS%,}"
   ```
   
   **2. Start the Connect server in the same terminal.**
   
   The Kafka connector belongs on the server classpath. Leave this process 
running
   and wait for it to report that it is listening on port 15002.
   
   ```bash
   "$SPARK_HOME/bin/spark-submit" \
     --master 'local[2]' \
     --class org.apache.spark.sql.connect.service.SparkConnectServer \
     --conf spark.ui.enabled=false \
     --conf spark.driver.bindAddress=127.0.0.1 \
     --conf spark.connect.grpc.binding.address=127.0.0.1 \
     --conf spark.connect.grpc.binding.port=15002 \
     --conf spark.port.maxRetries=0 \
     --jars "$KAFKA_REPRO_JARS" \
     "$SPARK_HOME/jars/spark-connect_2.13-4.1.1.jar"
   ```
   
   **3. Run the Scala Connect client in a second terminal.**
   
   Set `JAVA_HOME` in that terminal too, and change to the same reproduction 
directory.
   The client classpath contains the Connect client and its dependencies. 
Expect two
   `REPRODUCED` messages, each with `INTERNAL_ERROR` and the missing condition 
name.
   
   ```bash
   unset SPARK_REMOTE SPARK_CONNECT_MODE_ENABLED SPARK_API_MODE
   "$JAVA_HOME/bin/java" \
     --add-opens=java.base/java.nio=ALL-UNNAMED \
     -cp "$PWD/connect-repro.jar:$PWD/client-deps/jars/*" \
     ScalaConnectKafkaErrorRepro sc://127.0.0.1:15002
   ```
   
   **4. Stop the server with Ctrl+C, then run classic Scala in the first 
terminal.**
   
   Expect two `PASS` messages, each retaining the original Kafka condition and 
useful
   message. Both modes need the Kafka dependency JARs; omitting them produces
   `Failed to find data source: kafka`, which is a setup failure.
   
   ```bash
   "$SPARK_HOME/bin/spark-submit" \
     --master 'local[2]' \
     --class ScalaClassicKafkaErrorRepro \
     --conf spark.api.mode=classic \
     --conf spark.ui.enabled=false \
     --conf spark.driver.bindAddress=127.0.0.1 \
     --jars "$KAFKA_REPRO_JARS" \
     "$KAFKA_REPRO_DIR/classic-repro.jar"
   ```
   
   </details>
   
   ### Was this patch authored or co-authored using generative AI tooling?
   
   Generated-by: OpenAI Codex (GPT-6)
   


-- 
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