Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
16 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -239,6 +239,10 @@ class JSONOptions(
val useUnsafeRow: Boolean = parameters.get(USE_UNSAFE_ROW).map(_.toBoolean).getOrElse(
SQLConf.get.getConf(SQLConf.JSON_USE_UNSAFE_ROW))

val streamMultilineTopLevelArray: Boolean =
parameters.get(ENABLE_STREAMING_TOP_LEVEL_ARRAY).map(_.toBoolean).getOrElse(
SQLConf.get.getConf(SQLConf.JSON_STREAM_MULTILINE_TOP_LEVEL_ARRAY))

/** Build a Jackson [[JsonFactory]] using JSON options. */
def buildJsonFactory(): JsonFactory = {
val streamReadConstraints = StreamReadConstraints
Expand Down Expand Up @@ -338,6 +342,7 @@ object JSONOptions extends DataSourceOptions {
val SINGLE_VARIANT_COLUMN = newOption(DataSourceOptions.SINGLE_VARIANT_COLUMN)
val EXPLODE_EMBEDDED_ARRAY = newOption(DataSourceOptions.EXPLODE_EMBEDDED_ARRAY)
val USE_UNSAFE_ROW = newOption("useUnsafeRow")
val ENABLE_STREAMING_TOP_LEVEL_ARRAY = newOption("enableStreamingTopLevelArray")
// Options with alternative
val ENCODING = "encoding"
val CHARSET = "charset"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ package org.apache.spark.sql.catalyst.json

import java.io.{ByteArrayOutputStream, CharConversionException}
import java.nio.charset.MalformedInputException
import java.util.concurrent.atomic.AtomicReference

import scala.collection.mutable
import scala.collection.mutable.ArrayBuffer
Expand All @@ -28,6 +29,7 @@ import com.fasterxml.jackson.core._
import org.apache.hadoop.fs.PositionedReadable

import org.apache.spark.SparkUpgradeException
import org.apache.spark.TaskContext
import org.apache.spark.internal.Logging
import org.apache.spark.sql.catalyst.{InternalRow, NoopFilters, StructFilters}
import org.apache.spark.sql.catalyst.expressions._
Expand Down Expand Up @@ -61,6 +63,9 @@ class JacksonParser(
// `ValueConverter`s for the root schema for all fields in the schema
private val rootConverter = makeRootConverter(schema)

// Lazy: a schema that is not a struct never reaches either top-level-array path.
private lazy val arrayElementConverter = makeConverter(schema)

private val factory = options.buildJsonFactory()

private lazy val timestampFormatter = TimestampFormatter(
Expand Down Expand Up @@ -148,7 +153,6 @@ class JacksonParser(
}

private def makeStructRootConverter(st: StructType): JsonParser => Iterable[InternalRow] = {
val elementConverter = makeConverter(st)
val fieldConverters = st.map(_.dataType).map(makeConverter).toArray
val jsonFilters = if (SQLConf.get.jsonFilterPushDown) {
new JsonFilters(filters, st)
Expand All @@ -171,7 +175,8 @@ class JacksonParser(
// List([str_a_2,null], [null,str_b_3])
//
case START_ARRAY if allowArrayAsStructs =>
val array = convertArray(parser, elementConverter, isRoot = true, arrayAsStructs = true)
val array =
convertArray(parser, arrayElementConverter, isRoot = true, arrayAsStructs = true)
// Here, as we support reading top level JSON arrays and take every element
// in such an array as a row, this case is possible.
if (array.numElements() == 0) {
Expand Down Expand Up @@ -693,6 +698,34 @@ class JacksonParser(
case _ => err
}

private def badRecord(error: Throwable, recordLiteral: () => UTF8String): BadRecordException =
error match {
case e: SparkUpgradeException => throw e
case e: CharConversionException if options.encoding.isEmpty =>
val msg =
"""JSON parser cannot handle a character in its input.
|Specifying encoding as an input option explicitly might help to resolve the issue.
|""".stripMargin + e.getMessage
val wrappedCharException = new CharConversionException(msg)
wrappedCharException.initCause(e)
BadRecordException(recordLiteral, () => Array.empty, wrappedCharException)
case PartialResultException(row, cause) =>
BadRecordException(recordLiteral, () => Array(row), convertCauseForPartialResult(cause))
case PartialResultArrayException(rows, cause) =>
BadRecordException(recordLiteral, () => rows, cause)
case PartialArrayDataResultException(arrayData, cause) =>
BadRecordException(
recordLiteral,
() => Array(InternalRow(arrayData)),
convertCauseForPartialResult(cause))
case PartialMapDataResultException(mapData, cause) =>
BadRecordException(
recordLiteral,
() => Array(InternalRow(mapData)),
convertCauseForPartialResult(cause))
case e => BadRecordException(recordLiteral, () => Array.empty, e)
}

/**
* Parse the JSON input to the set of [[InternalRow]]s.
*
Expand All @@ -717,43 +750,122 @@ class JacksonParser(
}
} catch {
case e: SparkUpgradeException => throw e
case e @ (_: RuntimeException | _: JsonProcessingException | _: MalformedInputException) =>
// JSON parser currently doesn't support partial results for corrupted records.
// For such records, all fields other than the field configured by
// `columnNameOfCorruptRecord` are set to `null`.
throw BadRecordException(() => recordLiteral(record), () => Array.empty, e)
case e: CharConversionException if options.encoding.isEmpty =>
val msg =
"""JSON parser cannot handle a character in its input.
|Specifying encoding as an input option explicitly might help to resolve the issue.
|""".stripMargin + e.getMessage
val wrappedCharException = new CharConversionException(msg)
wrappedCharException.initCause(e)
throw BadRecordException(() => recordLiteral(record), () => Array.empty,
wrappedCharException)
case PartialResultException(row, cause) =>
throw BadRecordException(
record = () => recordLiteral(record),
partialResults = () => Array(row),
convertCauseForPartialResult(cause))
case PartialResultArrayException(rows, cause) =>
throw BadRecordException(
record = () => recordLiteral(record),
partialResults = () => rows,
cause)
// These exceptions should never be thrown outside of JacksonParser.
// They are used for the control flow in the parser. We add them here for completeness
// since they also indicate a bad record.
case PartialArrayDataResultException(arrayData, cause) =>
throw BadRecordException(
record = () => recordLiteral(record),
partialResults = () => Array(InternalRow(arrayData)),
convertCauseForPartialResult(cause))
case PartialMapDataResultException(mapData, cause) =>
throw BadRecordException(
record = () => recordLiteral(record),
partialResults = () => Array(InternalRow(mapData)),
convertCauseForPartialResult(cause))
throw badRecord(e, () => recordLiteral(record))
case e @ (_: RuntimeException | _: JsonProcessingException | _: MalformedInputException |
_: PartialResultException | _: PartialResultArrayException |
_: PartialArrayDataResultException | _: PartialMapDataResultException) =>
throw badRecord(e, () => recordLiteral(record))
}
}

private[sql] def parseIterator[T](
record: T,
createParser: (JsonFactory, T) => JsonParser,
recordLiteral: T => UTF8String): Iterator[InternalRow] = {
val streamArray = allowArrayAsStructs && schema.isInstanceOf[StructType] &&
options.singleVariantColumn.isEmpty && options.explodeEmbeddedArray.isEmpty
val jsonParser = try {
createParser(factory, record)
} catch {
case e: SparkUpgradeException => throw e
case e: CharConversionException if options.encoding.isEmpty =>
throw badRecord(e, () => recordLiteral(record))
case e @ (_: RuntimeException | _: JsonProcessingException | _: MalformedInputException |
_: PartialResultException | _: PartialResultArrayException |
_: PartialArrayDataResultException | _: PartialMapDataResultException) =>
throw badRecord(e, () => recordLiteral(record))
}
// An archive read builds one parser per entry inside a single task, so a closed parser left
// reachable from the completion listener keeps its entry's buffer alive for the rest of the
// task. Every close path takes the parser out of this cell first, so exactly one of them closes
// it and the listener stops retaining it as soon as one does.
val openParser = new AtomicReference(jsonParser)
def closeParser(): Unit = Option(openParser.getAndSet(null)).foreach(_.close())
// Abandoning the iterator before END_ARRAY (e.g. a LIMIT) reaches no eager close.
Option(TaskContext.get()).foreach(_.addTaskCompletionListener[Unit](_ => closeParser()))
def fail(error: Throwable): Nothing = {
try closeParser() catch {
case NonFatal(closeError) => error.addSuppressed(closeError)
}
throw badRecord(error, () => recordLiteral(record))
}
// Resuming is only safe where the parser cannot have moved past the element that failed. A
// scalar element leaves it on that scalar's own token, and `convertObject` consumes through
// END_OBJECT before raising `PartialResultException`; any other container failure can have
// stopped anywhere inside the element.
def resumableAfter(error: Throwable, elementStart: Option[JsonToken]): Boolean =
options.parseMode != FailFastMode && (elementStart match {
case Some(START_OBJECT) | Some(START_ARRAY) => error.isInstanceOf[PartialResultException]
case Some(_) => error.isInstanceOf[RuntimeException]
case None => false
})
def handleFailure[R](elementStart: Option[JsonToken])(operation: => R): R = {
try operation catch {
case e: SparkUpgradeException => fail(e)
case e: CharConversionException if options.encoding.isEmpty => fail(e)
case e @ (_: RuntimeException | _: JsonProcessingException | _: MalformedInputException |

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Non-blocking (P2): An incompatible scalar element reaches this terminal RuntimeException arm even though its token is already at a safe top-level array-element boundary. FailureSafeParser then replaces the source iterator with only the recovery result, so PERMISSIVE and DROPMALFORMED reads of an input such as [{"a":1},42,{"a":2}] silently lose the final valid row. Please classify this scalar-boundary failure as recoverable while keeping nested object and array mismatches terminal.

Recommended change: Classify only scalar element-type conversion failures that remain at the current top-level element boundary as recoverable in non-FAILFAST modes, and cover scalars before, between, and after valid objects across parse modes and archive/file paths.

Why this works: Inspect the current element token and error category at the array-loop owner. Convert a safe scalar mismatch into a recoverable BadRecordException so FailureSafeParser emits or drops that element and resumes the preserved iterator; keep nested containers and structurally ambiguous failures terminal.

Scope: Complete element-boundary recovery without broadening recovery to undefined Jackson token positions.

Compatibility: Existing partial-object recovery, terminal malformed-document handling, corrupt-record text, and the disabled eager path remain unchanged.

Risks: An overly broad classification could resume from inside a malformed nested container and duplicate or misparse later tokens. An overly narrow classification could continue dropping later valid objects after other safely consumed scalar forms.

Constraints: Resume only when parser position is known to be at a complete top-level element boundary. FAILFAST must still fail immediately. Terminal structural corruption must still close and stop the iterator.

Success: In PERMISSIVE mode, an incompatible scalar produces one recovery row and later valid objects exactly once. In DROPMALFORMED mode, the incompatible scalar is omitted and later valid objects remain. In FAILFAST mode, the first incompatible scalar still raises the established malformed-record error. Nested or structurally ambiguous failures remain terminal and close the parser.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done in dd76c16. Resumability is now decided by where the parser is left rather than by the exception alone. resumableAfter takes the element's start token: a scalar start makes any RuntimeException recoverable, since the parser is still on that scalar's own token; a container start still requires PartialResultException, which is the only container failure that provably drained to END_OBJECT. FailFastMode is excluded as before.

[{"a":1},42,{"a":2}] now yields (1,null), (null,D), (2,null) under PERMISSIVE and (1,null), (2,null) under DROPMALFORMED.

The parse-mode grid is parameterized over the malformed element's shape — a bad object field, a scalar, a nested array — against one document [{"a":1},<malformed>,{"a":2}], with the nested array as the negative control that has to stay terminal. A classification broad enough to resume from inside a container fails there.

Two notes on the framing. First, one consequence reaches beyond the input you cited: an all-scalar array such as [1,2,3] is malformed in every element against a struct schema, so it now reports one corrupt record per element rather than one per document. The existing SPARK-18352 multiline expectations are updated and made configuration-dependent for that. Object elements already behaved this way, through the partial-result path the eager reader has always had.

Second, on "silently lose the final valid row": for that input the disabled path loses more, not less — it yields a single (null,D) and drops both valid rows, because the scalar failure escapes convertArray before any element is accumulated. So the enabled path was already ahead of the one it replaces. The real defect was that element granularity was promised for every malformed element and delivered only for objects, which is what this fixes.

_: PartialResultException | _: PartialResultArrayException |
_: PartialArrayDataResultException | _: PartialMapDataResultException) =>
if (resumableAfter(e, elementStart)) {
throw badRecord(e, () => recordLiteral(record)).copy(recoverable = true)
} else {
fail(e)
}
}
}

handleFailure(elementStart = None)(jsonParser.nextToken()) match {
case null =>
closeParser()
Iterator.empty
case START_ARRAY if streamArray =>
new Iterator[InternalRow] {
private var nextRow: InternalRow = _
private var prepared = false
private var finished = false

override def hasNext: Boolean = {
prepare()
!finished
}

override def next(): InternalRow = {
prepare()
if (finished) throw new NoSuchElementException("next on empty iterator")
prepared = false
nextRow
}

private def prepare(): Unit = {
if (prepared || finished) return
handleFailure(elementStart = None)(jsonParser.nextToken()) match {
case END_ARRAY => finish()
// Unreachable today: jackson-core signals end of input inside an open array by
// throwing JsonEOFException, which handleFailure turns into a malformed record.
// Kept so a backend that returns null cannot fall through with no current token.
case null =>
fail(new JsonParseException(jsonParser, "Unexpected end of top-level array"))
case elementStart =>
nextRow = handleFailure(Some(elementStart)) {
val row = arrayElementConverter(jsonParser).asInstanceOf[InternalRow]
if (row == null) throw QueryExecutionErrors.rootConverterReturnNullError()
row
}
prepared = true
}
}

private def finish(): Unit = {
finished = true
closeParser()
}
}
case _ =>
val rows = handleFailure(elementStart = None)(rootConverter(jsonParser))
if (rows == null) fail(QueryExecutionErrors.rootConverterReturnNullError())
closeParser()
rows.iterator
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -76,11 +76,13 @@ case class PartialResultArrayException(
* @param cause the actual exception about why the record is bad and can't be parsed. It's better
* to use `LazyBadRecordCauseWrapper` here to delay heavy cause construction
* until it's needed.
* @param recoverable whether the parser can resume at the next record after handling this failure
*/
case class BadRecordException(
@transient record: () => UTF8String,
@transient partialResults: () => Array[InternalRow] = () => Array.empty[InternalRow],
cause: Throwable) extends Exception(cause) {
cause: Throwable,
recoverable: Boolean = false) extends Exception(cause) {
override def getStackTrace(): Array[StackTraceElement] = new Array[StackTraceElement](0)
override def fillInStackTrace(): Throwable = this
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -59,30 +59,63 @@ class FailureSafeParser[IN](
try {
rawParser.apply(input).iterator.map(row => toResultRow(Some(row), () => null))
} catch {
case e: BadRecordException => mode match {
case PermissiveMode =>
val partialResults = e.partialResults()
if (partialResults.nonEmpty) {
partialResults.iterator.map(row => toResultRow(Some(row), e.record))
} else {
Iterator(toResultRow(None, e.record))
}
case DropMalformedMode =>
Iterator.empty
case FailFastMode =>
e.getCause match {
case _: JsonArraysAsStructsException =>
// SPARK-42298 we recreate the exception here to make sure the error message
// have the record content.
throw QueryExecutionErrors.cannotParseJsonArraysAsStructsError(e.record().toString)
case StringAsDataTypeException(fieldName, fieldValue, dataType) =>
throw QueryExecutionErrors.cannotParseStringAsDataTypeError(e.record().toString,
fieldName, fieldValue, dataType)
case causeWrapper: LazyBadRecordCauseWrapper =>
throwMalformedRecordsDetectedInRecordParsingError(e, causeWrapper.cause())
case cause => throwMalformedRecordsDetectedInRecordParsingError(e, cause)
case e: BadRecordException => parseFailure(e)
}
}

def parseIterator(
input: IN,
iteratorParser: IN => Iterator[InternalRow]): Iterator[InternalRow] = {
var delegate = try {
iteratorParser.apply(input).map(row => toResultRow(Some(row), () => null))
} catch {
case e: BadRecordException => parseFailure(e)
}
new Iterator[InternalRow] {
private def handleFailure[T](operation: Iterator[InternalRow] => T): T = {
while (true) {
try {
return operation(delegate)
} catch {
case e: BadRecordException =>
val source = delegate
val recovery = parseFailure(e)
delegate = if (e.recoverable) recovery ++ source else recovery
}
}
throw new IllegalStateException("unreachable")
}

override def hasNext: Boolean = handleFailure(_.hasNext)

override def next(): InternalRow = handleFailure(_.next())
}
}

private def parseFailure(e: BadRecordException): Iterator[InternalRow] = {
mode match {
case PermissiveMode =>
val partialResults = e.partialResults()
if (partialResults.nonEmpty) {
partialResults.iterator.map(row => toResultRow(Some(row), e.record))
} else {
Iterator(toResultRow(None, e.record))
}
case DropMalformedMode =>
Iterator.empty
case FailFastMode =>
e.getCause match {
case _: JsonArraysAsStructsException =>
// SPARK-42298 we recreate the exception here to make sure the error message
// has the record content.
throw QueryExecutionErrors.cannotParseJsonArraysAsStructsError(e.record().toString)
case StringAsDataTypeException(fieldName, fieldValue, dataType) =>
throw QueryExecutionErrors.cannotParseStringAsDataTypeError(e.record().toString,
fieldName, fieldValue, dataType)
case causeWrapper: LazyBadRecordCauseWrapper =>
throwMalformedRecordsDetectedInRecordParsingError(e, causeWrapper.cause())
case cause => throwMalformedRecordsDetectedInRecordParsingError(e, cause)
}
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7034,6 +7034,25 @@ object SQLConf {
.booleanConf
.createWithDefault(true)

val JSON_STREAM_MULTILINE_TOP_LEVEL_ARRAY =
buildConf("spark.sql.json.enableStreamingTopLevelArray")
.internal()
.doc("When true, multiline JSON reads stream the elements of a top-level array one at a " +

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit (P3): This description makes the setting sound unconditional, but the parser only selects the streaming path when neither singleVariantColumn nor explodeEmbeddedArray is active. With either option, the top-level array is still converted eagerly. Please qualify the description so it states the actual option scope.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done. The doc now states the scope: streaming applies only to reads into a struct schema that take top-level arrays as structs, and reads using singleVariantColumn or explodeEmbeddedArray are never streamed.

"time instead of materializing the entire array before returning rows. This applies only " +
"to reads into a struct schema that take top-level arrays as structs; reads using the " +
"`singleVariantColumn` or `explodeEmbeddedArray` option are never streamed. Streaming " +
"also makes an array element, rather than the whole document, the record that a parse " +
"mode applies to, since rows already emitted cannot be withdrawn: PERMISSIVE fills the " +
"corrupt record column for the malformed element only, leaving it null on the valid rows " +
"of the same document, and DROPMALFORMED drops that element rather than the whole " +
"document. An element whose failure leaves the parser at an unknown position, such as a " +
"nested value of the wrong shape, still ends the document. It can be overwritten by the " +
"JSON option `enableStreamingTopLevelArray`.")
.version("4.4.0")
.withBindingPolicy(ConfigBindingPolicy.SESSION)
.booleanConf
.createWithDefault(false)

val JSON_USE_UNSAFE_ROW =
buildConf("spark.sql.json.useUnsafeRow")
.doc("When set to true, use UnsafeRow to represent struct result in the JSON parser. It " +
Expand Down
Loading