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

cloud-fan pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/spark.git


The following commit(s) were added to refs/heads/master by this push:
     new e1d39c6cb1bf [SPARK-57195][SQL] Surface MALFORMED_CSV_RECORD instead 
of ArrayIndexOutOfBoundsException in CSV schema inference
e1d39c6cb1bf is described below

commit e1d39c6cb1bf851e2a2ac8c2ee28dbce1fde1822
Author: Yash Botadra <[email protected]>
AuthorDate: Sun Jun 14 10:58:29 2026 -0700

    [SPARK-57195][SQL] Surface MALFORMED_CSV_RECORD instead of 
ArrayIndexOutOfBoundsException in CSV schema inference
    
    ### What changes were proposed in this pull request?
    
    CSV schema inference can fail with an uncaught 
`java.lang.ArrayIndexOutOfBoundsException` when a row has more columns than 
`maxColumns`. [SPARK-49444](https://issues.apache.org/jira/browse/SPARK-49444) 
added handling for the per-line `UnivocityParser.parseLine` path, but the 
schema-inference paths that tokenize with a raw Univocity `CsvParser` were 
never covered, so they still surface the internal exception.
    
    This PR translates that exception into a `MALFORMED_CSV_RECORD` error 
across the remaining paths:
    
    - Adds a shared `UnivocityParser.parseLine(tokenizer, line)` helper that 
converts Univocity's `ArrayIndexOutOfBoundsException` (raised bare or wrapped 
in a `TextParsingException`) into `MALFORMED_CSV_RECORD`.
    - The bad record in the message is bounded to 
CSVOptions.MAX_ERROR_CONTENT_LENGTH
      (the same bound Univocity uses).
    - Guards the streaming tokenizer `UnivocityParser.convertStream`, used by 
`multiLine` reads and `multiLine` schema inference.
    - Routes the non-`multiLine` inference path 
(`TextInputCSVDataSource.inferFromDataset`).
    
    Note: Header rows are out of scope from this PR. A header over `maxColumns` 
still surfaces the raw AIOOBE (`CSVHeaderChecker`), a pre-existing gap.
    
    ### Why are the changes needed?
    
    Schema inference and `multiLine` reads crash with an internal 
`ArrayIndexOutOfBoundsException` for input that should produce a clean 
`MALFORMED_CSV_RECORD` error. SPARK-49444 only covered 
`UnivocityParser.parseLine`; the inference paths construct a raw `CsvParser` 
and call `parseLine` on it directly, bypassing that handling.
    
    ### Does this PR introduce _any_ user-facing change?
    
    Yes.
    - When a CSV row has more columns than `maxColumns` during schema inference 
(or a `multiLine` read), the surfaced error changes from an internal 
`java.lang.ArrayIndexOutOfBoundsException` to `MALFORMED_CSV_RECORD` (SQLSTATE 
`KD000`), consistent with the non-`multiLine` per-row read path since 
SPARK-49444. This is a change relative to all released versions.
    - A maxCharsPerColumn overflow on the guarded paths also changes error 
class (`TextParsingException` -> `MALFORMED_CSV_RECORD`), because the 
direct-cause AIOOBE check can't distinguish it from `maxColumns`; the 
bad-record content is bounded to 1000 chars.
    
    ### How was this patch tested?
    
    Added unit tests in CSVSuite covering schema inference for the multiLine 
and non-multiLine paths, plus the multiLine read path, asserting 
MALFORMED_CSV_RECORD instead of a raw ArrayIndexOutOfBoundsException.
    
    ### Was this patch authored or co-authored using generative AI tooling?
    
    Generated-by: Claude Code (Anthropic), Claude Opus 4.8
    
    Closes #56260 from yashtc/csv-infer-parsenext-malformed.
    
    Lead-authored-by: Yash Botadra <[email protected]>
    Co-authored-by: Yash Botadra <[email protected]>
    Signed-off-by: Wenchen Fan <[email protected]>
---
 .../apache/spark/sql/catalyst/csv/CSVOptions.scala |  6 +-
 .../spark/sql/catalyst/csv/UnivocityParser.scala   | 64 +++++++++++++----
 .../execution/datasources/csv/CSVDataSource.scala  |  5 +-
 .../sql/execution/datasources/csv/CSVSuite.scala   | 82 ++++++++++++++++++++++
 4 files changed, 140 insertions(+), 17 deletions(-)

diff --git 
a/sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/csv/CSVOptions.scala
 
b/sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/csv/CSVOptions.scala
index 4ae7aad19a9c..30b6877d3a82 100644
--- 
a/sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/csv/CSVOptions.scala
+++ 
b/sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/csv/CSVOptions.scala
@@ -250,7 +250,7 @@ class CSVOptions(
   /**
    * The max error content length in CSV parser/writer exception message.
    */
-  val maxErrorContentLength = 1000
+  val maxErrorContentLength = CSVOptions.MAX_ERROR_CONTENT_LENGTH
 
   val isCommentSet = parameters.get(COMMENT) match {
     case Some(value) if value.length == 1 => true
@@ -443,4 +443,8 @@ object CSVOptions extends DataSourceOptions {
   newOption(SEP, DELIMITER)
   val COLUMN_PRUNING = newOption("columnPruning")
   val SINGLE_VARIANT_COLUMN = 
newOption(DataSourceOptions.SINGLE_VARIANT_COLUMN)
+
+  // Max error content length in CSV parser/writer exception messages, and the 
bound on the bad
+  // record embedded in MALFORMED_CSV_RECORD errors.
+  val MAX_ERROR_CONTENT_LENGTH = 1000
 }
diff --git 
a/sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/csv/UnivocityParser.scala
 
b/sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/csv/UnivocityParser.scala
index 2073c5922fea..113f9b088738 100644
--- 
a/sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/csv/UnivocityParser.scala
+++ 
b/sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/csv/UnivocityParser.scala
@@ -314,19 +314,8 @@ class UnivocityParser(
     }
   }
 
-  private def parseLine(line: String): Array[String] = {
-    try {
-      tokenizer.parseLine(line)
-    }
-    catch {
-      case e: TextParsingException if 
e.getCause.isInstanceOf[ArrayIndexOutOfBoundsException] =>
-        throw new SparkRuntimeException(
-          errorClass = "MALFORMED_CSV_RECORD",
-          messageParameters = Map("badRecord" -> line),
-          cause = e
-        )
-    }
-  }
+  private def parseLine(line: String): Array[String] =
+    UnivocityParser.parseLine(tokenizer, line)
 
   /**
    * Parses a single CSV string and turns it into either one resulting row or 
no row (if the
@@ -634,7 +623,19 @@ private[sql] object UnivocityParser {
     // We can handle header here since here the stream is open.
     handleHeader()
 
-    private var nextRecord = tokenizer.parseNext()
+    // The bad-record text is not available here, so use the bounded parsed 
content when present.
+    private def parseNextRecord(): Array[String] = {
+      try {
+        tokenizer.parseNext()
+      } catch {
+        case e: TextParsingException if 
e.getCause.isInstanceOf[ArrayIndexOutOfBoundsException] =>
+          throw malformedCsvRecord(e, Option(e.getParsedContent).getOrElse(""))
+        case e: ArrayIndexOutOfBoundsException =>
+          throw malformedCsvRecord(e, "")
+      }
+    }
+
+    private var nextRecord = parseNextRecord()
 
     override def hasNext: Boolean = nextRecord != null
 
@@ -643,11 +644,44 @@ private[sql] object UnivocityParser {
         throw QueryExecutionErrors.endOfStreamError()
       }
       val curRecord = convert(nextRecord)
-      nextRecord = tokenizer.parseNext()
+      nextRecord = parseNextRecord()
       curRecord
     }
   }
 
+  /**
+   * Builds a MALFORMED_CSV_RECORD error from a Univocity malformed-record 
failure. The bad record
+   * is bounded to CSVOptions.MAX_ERROR_CONTENT_LENGTH so an oversized value 
cannot produce a huge
+   * error message (SPARK-28431).
+   */
+  private def malformedCsvRecord(cause: Throwable, badRecord: String): 
SparkRuntimeException = {
+    val boundedRecord = if (badRecord.length > 
CSVOptions.MAX_ERROR_CONTENT_LENGTH) {
+      badRecord.take(CSVOptions.MAX_ERROR_CONTENT_LENGTH) + "..."
+    } else {
+      badRecord
+    }
+    new SparkRuntimeException(
+      errorClass = "MALFORMED_CSV_RECORD",
+      messageParameters = Map("badRecord" -> boundedRecord),
+      cause = cause)
+  }
+
+  /**
+   * Parses a single CSV line, translating a Univocity malformed-record
+   * ArrayIndexOutOfBoundsException into MALFORMED_CSV_RECORD so the per-line 
and streaming paths
+   * fail consistently.
+   */
+  def parseLine(tokenizer: CsvParser, line: String): Array[String] = {
+    try {
+      tokenizer.parseLine(line)
+    } catch {
+      case e: TextParsingException if 
e.getCause.isInstanceOf[ArrayIndexOutOfBoundsException] =>
+        throw malformedCsvRecord(e, line)
+      case e: ArrayIndexOutOfBoundsException =>
+        throw malformedCsvRecord(e, line)
+    }
+  }
+
   /**
    * Parses an iterator that contains CSV strings and turns it into an 
iterator of rows.
    */
diff --git 
a/sql/core/src/main/scala/org/apache/spark/sql/execution/datasources/csv/CSVDataSource.scala
 
b/sql/core/src/main/scala/org/apache/spark/sql/execution/datasources/csv/CSVDataSource.scala
index 5170ce8a3da3..7424d43341b2 100644
--- 
a/sql/core/src/main/scala/org/apache/spark/sql/execution/datasources/csv/CSVDataSource.scala
+++ 
b/sql/core/src/main/scala/org/apache/spark/sql/execution/datasources/csv/CSVDataSource.scala
@@ -362,7 +362,10 @@ object TextInputCSVDataSource extends CSVDataSource {
           val linesWithoutHeader =
             CSVUtils.filterHeaderLine(filteredLines, maybeFirstLine.get, 
parsedOptions)
           val parser = new CsvParser(parsedOptions.asParserSettings)
-          linesWithoutHeader.map(parser.parseLine)
+          // Route data rows through UnivocityParser.parseLine so a 
too-many-columns row surfaces as
+          // MALFORMED_CSV_RECORD, not a raw ArrayIndexOutOfBoundsException 
(SPARK-57195). The
+          // first-line parse above stays raw to keep SPARK-28431's bounded 
TextParsingException.
+          linesWithoutHeader.map(UnivocityParser.parseLine(parser, _))
         }
         SQLExecution.withSQLConfPropagated(csv.sparkSession) {
           new CSVInferSchema(parsedOptions).infer(tokenRDD, header)
diff --git 
a/sql/core/src/test/scala/org/apache/spark/sql/execution/datasources/csv/CSVSuite.scala
 
b/sql/core/src/test/scala/org/apache/spark/sql/execution/datasources/csv/CSVSuite.scala
index 22b291677cd8..e48b453309aa 100644
--- 
a/sql/core/src/test/scala/org/apache/spark/sql/execution/datasources/csv/CSVSuite.scala
+++ 
b/sql/core/src/test/scala/org/apache/spark/sql/execution/datasources/csv/CSVSuite.scala
@@ -3506,6 +3506,88 @@ abstract class CSVSuite
     
assert(textParsingException.getCause.isInstanceOf[ArrayIndexOutOfBoundsException])
   }
 
+  test("SPARK-57195: multiLine CSV schema inference surfaces 
MALFORMED_CSV_RECORD for a row " +
+    "exceeding maxColumns") {
+    // multiLine schema inference reads through 
UnivocityParser.tokenizeStream, whose parseNext
+    // call was unguarded (SPARK-49444 only fixed the per-line parseLine 
path). A row with more
+    // columns than maxColumns must surface as MALFORMED_CSV_RECORD, not a raw
+    // ArrayIndexOutOfBoundsException. The overflow is on a later row so it is 
hit during inference.
+    withTempPath { path =>
+      Files.write(path.toPath, 
"a,b\nc,d\n1,2,3\n".getBytes(StandardCharsets.UTF_8))
+      val e = intercept[SparkRuntimeException] {
+        spark.read
+          .option("header", "false")
+          .option("inferSchema", "true")
+          .option("multiLine", "true")
+          .option("maxColumns", "2")
+          .csv(path.getAbsolutePath)
+      }
+      // badRecord comes from TextParsingException.getParsedContent (bounded), 
so its exact value
+      // is not pinned; ".*" keeps the error class, sqlState, and parameter 
validation.
+      checkError(
+        exception = e,
+        condition = "MALFORMED_CSV_RECORD",
+        sqlState = Some("KD000"),
+        parameters = Map("badRecord" -> ".*"),
+        matchPVals = true)
+    }
+  }
+
+  test("SPARK-57195: non-multiLine CSV schema inference surfaces 
MALFORMED_CSV_RECORD for a row " +
+    "exceeding maxColumns") {
+    // Without multiLine, inference reads through 
TextInputCSVDataSource.inferFromDataset, which
+    // parsed each line with a raw Univocity CsvParser, bypassing the guarded 
parseLine. A row with
+    // more columns than maxColumns must surface as MALFORMED_CSV_RECORD, not 
a raw
+    // ArrayIndexOutOfBoundsException.
+    withTempPath { path =>
+      Files.write(path.toPath, 
"a,b\nc,d\n1,2,3\n".getBytes(StandardCharsets.UTF_8))
+      val e = intercept[SparkRuntimeException] {
+        spark.read
+          .option("header", "false")
+          .option("inferSchema", "true")
+          .option("maxColumns", "2")
+          .csv(path.getAbsolutePath)
+      }
+      checkError(
+        exception = e,
+        condition = "MALFORMED_CSV_RECORD",
+        parameters = Map("badRecord" -> "1,2,3"),
+        sqlState = "KD000")
+    }
+  }
+
+  test("SPARK-57195: multiLine CSV read failure with more than max columns") {
+    // The multiLine read path (parseStream) uses the same guarded streaming 
tokenizer as inference.
+    // With an explicit schema, an overflow row surfaces as 
MALFORMED_CSV_RECORD wrapped in
+    // FAILED_READ_FILE, mirroring the non-multiLine SPARK-49444 test above.
+    val schema = new StructType()
+      .add("intColumn", IntegerType, nullable = true)
+      .add("decimalColumn", DecimalType(10, 2), nullable = true)
+
+    val fileReadException = intercept[SparkException] {
+      spark.read
+        .schema(schema)
+        .option("header", "false")
+        .option("multiLine", "true")
+        .option("maxColumns", "2")
+        .csv(testFile(moreColumnsFile))
+        .collect()
+    }
+
+    checkErrorMatchPVals(
+      exception = fileReadException,
+      condition = "FAILED_READ_FILE.NO_HINT",
+      parameters = Map("path" -> s".*$moreColumnsFile"))
+
+    val malformedCSVException = 
fileReadException.getCause.asInstanceOf[SparkRuntimeException]
+    checkError(
+      exception = malformedCSVException,
+      condition = "MALFORMED_CSV_RECORD",
+      sqlState = Some("KD000"),
+      parameters = Map("badRecord" -> ".*"),
+      matchPVals = true)
+  }
+
   test("csv with variant") {
     withTempPath { path =>
       val data =


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

Reply via email to