This is an automated email from the ASF dual-hosted git repository.
cloud-fan pushed a commit to branch branch-4.3
in repository https://gitbox.apache.org/repos/asf/spark.git
The following commit(s) were added to refs/heads/branch-4.3 by this push:
new b4f78fe05b0c [SPARK-58248][SQL] Reuse single-line inference path for
JSON/XML archive inference
b4f78fe05b0c is described below
commit b4f78fe05b0c8e7e90876743cf419c7c989940e8
Author: akshatshenoi-db <[email protected]>
AuthorDate: Wed Aug 5 10:47:04 2026 +0800
[SPARK-58248][SQL] Reuse single-line inference path for JSON/XML archive
inference
### What changes were proposed in this pull request?
`JsonDataSource` and `XmlDataSource` each carried a custom per-entry,
line-delimited
archive-schema-inference branch in `inferWithArchives` (added alongside
JSON/XML archive support):
for single-line mode they streamed every archive entry and loose file,
split the bytes into lines,
and fed them through a bespoke `JsonInferSchema` / `XmlInferSchema` pass.
This duplicated the
format's existing single-line inference path
(`TextInput{Json,Xml}DataSource.infer` via
`createBaseDataset` / `inferFromDataset`), which already tokenizes
line-delimited records by
delegating to the Text data source.
That duplication was only necessary because the Text data source could not
read archives. Since
SPARK-57705 added archive support to `TextFileFormat`, that is no longer
the case, so this PR
removes the custom single-line branch in both `inferWithArchives` methods.
Single-line archive
schema inference now falls through the dispatch in `inferSchema` to the
normal `infer` path — the
exact same path a directory or loose-file read takes — so it is identical
to a directory read by
construction. `inferWithArchives` moves into the multi-line data source and
is multi-line-only
(it reads whole streams / `rowTag`-tokenizes, which has no line model to
reuse).
The DSv2 archive-inference refuse guard is kept: DSv2 archive reads are
unsupported (the v2 scan
reads raw file bytes and never routes archives through `readArchive`), so
the `supportsArchiveScan`
parameter on `inferSchema` still makes the DSv2 callers (`{CSV,Json}Table`)
return `None`
(`UNABLE_TO_INFER_SCHEMA`) for an archive input, failing loudly instead of
letting the v2 scan
mis-read raw archive bytes. Only the v1 callers (`{Csv,Json}FileFormat`)
pass
`supportsArchiveScan = true`. This is unchanged behavior; the refactor
above is orthogonal to it.
### Why are the changes needed?
The custom single-line inference branch was a workaround for the Text data
source's lack of archive
support; that support now exists, so the workaround is dead code that can
diverge from the real
single-line read path. Removing it makes single-line archive inference
identical to a directory read
by construction rather than by a parallel implementation.
### Does this PR introduce _any_ user-facing change?
No. This is a behavior-preserving refactor; single-line archive inference
produces the same schema
(now via the shared path), multi-line inference is unchanged, and the DSv2
path still does not read
archives.
### How was this patch tested?
Existing archive test suites already assert that single-line archive
inference equals the directory
read (`inferredSchema(archive) == inferredSchema(dir)`), cross-entry type
widening, corrupt-entry
skip, and null-across-entries widening — these are unchanged and remain the
safety net
(`JSON{Tar,Zip}ArchiveReadSuite`, `XML{Tar,Zip}ArchiveReadSuite`), as are
the two DSv2-refuse tests
(`CSV/JSON: the DSv2 path refuses to infer a schema for an archive`). The
corrupt-entry skip now
also covers a corrupt entry reached while advancing through a multi-entry
archive during inference,
not only the first entry.
### Was this patch authored or co-authored using generative AI tooling?
Generated-by: Claude Code (Opus 4.8)
Closes #57414 from akshatshenoi-db/archive-infer-reuse.
Authored-by: akshatshenoi-db <[email protected]>
Signed-off-by: Wenchen Fan <[email protected]>
(cherry picked from commit 65a8e236fe2f039d9763814fe7a6c72f0eab2fc6)
Signed-off-by: Wenchen Fan <[email protected]>
---
.../execution/datasources/csv/CSVDataSource.scala | 8 +-
.../execution/datasources/csv/CSVFileFormat.scala | 2 -
.../datasources/json/JsonDataSource.scala | 198 +++++++++++----------
.../execution/datasources/v2/csv/CSVTable.scala | 4 +-
.../execution/datasources/v2/json/JsonTable.scala | 2 -
.../execution/datasources/xml/XmlDataSource.scala | 168 +++++++++--------
.../datasources/ArchiveReadSuiteBase.scala | 13 ++
.../execution/datasources/CSVArchiveReadBase.scala | 7 +-
.../datasources/JSONArchiveReadBase.scala | 27 ++-
.../datasources/SevenZArchiveReadBase.scala | 8 +
.../datasources/TarArchiveTestUtils.scala | 32 +++-
.../execution/datasources/XMLArchiveReadBase.scala | 22 +++
.../datasources/ZipArchiveTestUtils.scala | 9 +
13 files changed, 307 insertions(+), 193 deletions(-)
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 2eb0ad44f472..ddaf8ba2539c 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
@@ -76,14 +76,10 @@ abstract class CSVDataSource extends Serializable with
Logging with SupportsArch
val hasArchive = parsedOptions.archiveFormatEnabled &&
inputPaths.exists(f =>
SupportsArchiveFormat.isArchivePath(f.getPath))
if (hasArchive && supportsArchiveScan) {
- // Archives (and any loose files alongside them) are inferred in a
single CSVInferSchema
- // pass over all inputs -- archive entries are streamed, never
unpacked -- so the result
- // matches what the scan returns for the same files.
Some(inferWithArchives(sparkSession, inputPaths, parsedOptions))
} else if (hasArchive) {
- // The caller's scan path cannot read archives (e.g. the DSv2
reader), so refuse to infer
- // a schema when any input is an archive: returning None raises
UNABLE_TO_INFER_SCHEMA,
- // which fails loudly instead of letting the scan parse raw archive
bytes as CSV.
+ // The caller's scan cannot read archives (e.g. DSv2), so refuse
rather than let it
+ // mis-read raw archive bytes as CSV.
None
} else if (inputPaths.nonEmpty) {
Some(infer(sparkSession, inputPaths, parsedOptions))
diff --git
a/sql/core/src/main/scala/org/apache/spark/sql/execution/datasources/csv/CSVFileFormat.scala
b/sql/core/src/main/scala/org/apache/spark/sql/execution/datasources/csv/CSVFileFormat.scala
index a406fbe4844d..0bfee12d1666 100644
---
a/sql/core/src/main/scala/org/apache/spark/sql/execution/datasources/csv/CSVFileFormat.scala
+++
b/sql/core/src/main/scala/org/apache/spark/sql/execution/datasources/csv/CSVFileFormat.scala
@@ -57,8 +57,6 @@ case class CSVFileFormat() extends TextBasedFileFormat with
DataSourceRegister {
options: Map[String, String],
files: Seq[FileStatus]): Option[StructType] = {
val parsedOptions = getCsvOptions(sparkSession, options)
- // The v1 file format routes archives to `readArchive` (see
`buildReader`), so archive schema
- // inference is supported here.
CSVDataSource(parsedOptions)
.inferSchema(sparkSession, files, parsedOptions, supportsArchiveScan =
true)
}
diff --git
a/sql/core/src/main/scala/org/apache/spark/sql/execution/datasources/json/JsonDataSource.scala
b/sql/core/src/main/scala/org/apache/spark/sql/execution/datasources/json/JsonDataSource.scala
index a5f19ae353d4..42568a2e6b0a 100644
---
a/sql/core/src/main/scala/org/apache/spark/sql/execution/datasources/json/JsonDataSource.scala
+++
b/sql/core/src/main/scala/org/apache/spark/sql/execution/datasources/json/JsonDataSource.scala
@@ -17,9 +17,8 @@
package org.apache.spark.sql.execution.datasources.json
-import java.io.{ByteArrayInputStream, FileNotFoundException, InputStream,
IOException}
+import java.io.{ByteArrayInputStream, Closeable, FileNotFoundException,
InputStream, IOException}
-import scala.reflect.ClassTag
import scala.util.control.NonFatal
import com.fasterxml.jackson.core.{JsonFactory, JsonParser}
@@ -105,15 +104,9 @@ abstract class JsonDataSource extends Serializable with
Logging with SupportsArc
case None =>
val hasArchive = parsedOptions.archiveFormatEnabled &&
inputPaths.exists(f =>
SupportsArchiveFormat.isArchivePath(f.getPath))
- if (hasArchive && supportsArchiveScan) {
- // Archives (and any loose files alongside them) are inferred in a
single JsonInferSchema
- // pass over all inputs -- archive entries are streamed, never
unpacked -- so the result
- // matches what the scan returns for the same files.
- Some(inferWithArchives(sparkSession, inputPaths, parsedOptions))
- } else if (hasArchive) {
- // The caller's scan path cannot read archives (e.g. the DSv2
reader), so refuse to infer
- // a schema when any input is an archive: returning None raises
UNABLE_TO_INFER_SCHEMA,
- // which fails loudly instead of letting the scan parse raw archive
bytes as JSON.
+ if (hasArchive && !supportsArchiveScan) {
+ // The caller's scan cannot read archives (e.g. DSv2), so refuse
rather than let it
+ // mis-read raw archive bytes as JSON.
None
} else if (inputPaths.nonEmpty) {
Some(infer(sparkSession, inputPaths, parsedOptions))
@@ -127,92 +120,6 @@ abstract class JsonDataSource extends Serializable with
Logging with SupportsArc
sparkSession: SparkSession,
inputPaths: Seq[FileStatus],
parsedOptions: JSONOptions): StructType
-
- /**
- * Infers a JSON schema when at least one input is a tar archive. Every
archive entry (streamed
- * via `SupportsArchiveFormat`, never unpacked to disk) and every loose file
is read as JSON
- * records -- each line is a record, or the whole input is one document in
multi-line mode -- and
- * all of them feed a single [[JsonInferSchema]] pass, exactly as a
directory of the same files
- * would infer.
- * Because [[JsonInferSchema]] already merges every record's type by field
name across all inputs,
- * one pass is itself the union: a field empty in one input but typed in
another widens to the
- * real type, and a `NullType` field survives to the single final
canonicalization rather than
- * being collapsed per-input. A corrupt/missing input is skipped as a unit
(a whole archive or a
- * whole file) when `ignoreCorruptFiles`/`ignoreMissingFiles` are set.
- */
- private def inferWithArchives(
- sparkSession: SparkSession,
- inputPaths: Seq[FileStatus],
- parsedOptions: JSONOptions): StructType = {
- val baseRdd = JsonDataSource.createBaseRdd(sparkSession, inputPaths,
parsedOptions)
- val multiLine = parsedOptions.multiLine
- val lineSeparator = parsedOptions.lineSeparatorInRead
- val encoding = parsedOptions.encoding
- val ignoreCorruptFiles = parsedOptions.ignoreCorruptFiles
- val ignoreMissingFiles = parsedOptions.ignoreMissingFiles
-
- // Applies `perEntry` to each input -- once per archive entry, once for a
loose file -- skipping
- // a whole input when it is corrupt/missing and the ignore flags are set.
The entry/file stream
- // is consumed lazily by `perEntry`, never buffered whole; mirrors CSV's
`inferWithArchives`.
- //
- // An archive entry's stream is a `CloseShieldInputStream` view over the
one shared
- // `TarArchiveInputStream` cursor, valid only until `readEntries` advances
to the next entry
- // (`getNextEntry` skips the prior entry's unread bytes). A consumer that
emits the raw stream
- // (the multiLine path below) must therefore fully consume each element
before the iterator
- // advances; `JsonInferSchema.infer` does, parsing each record before
pulling the next.
- // Buffering, look-ahead, or parallelizing the per-partition consumption
would read from an
- // advanced cursor and infer a wrong schema. The line-delimited path
sidesteps this by
- // materializing each record (`copyBytes()`).
- def perInput[T: ClassTag](perEntry: InputStream => Iterator[T]): RDD[T] =
baseRdd.flatMap {
- stream =>
- val path = new Path(stream.getPath())
- try {
- if (SupportsArchiveFormat.isArchivePath(path)) {
- SupportsArchiveFormat.readArchiveEntries(path,
stream.getConfiguration) { (_, in) =>
- perEntry(in)
- }
- } else {
- perEntry(
-
CodecStreams.createInputStreamWithCloseResource(stream.getConfiguration, path))
- }
- } catch {
- case e: FileNotFoundException if ignoreMissingFiles =>
- logWarning(log"Skipped missing input: ${MDC(PATH,
stream.getPath())}", e)
- Iterator.empty
- case e: FileNotFoundException => throw e
- case e @ (_: RuntimeException | _: IOException) if
ignoreCorruptFiles =>
- logWarning(log"Skipped the corrupted input: ${MDC(PATH,
stream.getPath())}", e)
- Iterator.empty
- case NonFatal(e) =>
- throw QueryExecutionErrors.cannotReadFilesError(
- e, SparkPath.fromPathString(stream.getPath()).urlEncoded)
- }
- }
-
- SQLExecution.withSQLConfPropagated(sparkSession) {
- val inferSchema = new JsonInferSchema(parsedOptions)
- if (multiLine) {
- // Each input/entry is one JSON document: hand its stream straight to
the parser
- // (`CreateJacksonParser.inputStream`, matching
MultiLineJsonDataSource and its charset
- // auto-detect) so the document is parsed incrementally rather than
buffered.
- val docs = perInput(in => Iterator.single(in))
- val docParser: (JsonFactory, InputStream) => JsonParser = encoding
- .map(enc => CreateJacksonParser.inputStream(enc, _: JsonFactory, _:
InputStream))
- .getOrElse(CreateJacksonParser.inputStream(_: JsonFactory, _:
InputStream))
- inferSchema.infer[InputStream](
- JsonUtils.sample(docs, parsedOptions), docParser, isReadFile = true)
- } else {
- // Line-delimited: each line is a record, copied off the reused line
buffer and parsed from
- // its bytes (`CreateJacksonParser.bytes`, matching
TextInputJsonDataSource).
- val lines = perInput(in => lineIterator(in,
lineSeparator).map(_.copyBytes()))
- val lineParser: (JsonFactory, Array[Byte]) => JsonParser = encoding
- .map(enc => CreateJacksonParser.bytes(enc, _: JsonFactory, _:
Array[Byte]))
- .getOrElse(CreateJacksonParser.bytes(_: JsonFactory, _: Array[Byte]))
- inferSchema.infer[Array[Byte]](
- JsonUtils.sample(lines, parsedOptions), lineParser, isReadFile =
false)
- }
- }
- }
}
object JsonDataSource {
@@ -344,6 +251,12 @@ object MultiLineJsonDataSource extends JsonDataSource {
sparkSession: SparkSession,
inputPaths: Seq[FileStatus],
parsedOptions: JSONOptions): StructType = {
+ val hasArchive = parsedOptions.archiveFormatEnabled &&
+ inputPaths.exists(f => SupportsArchiveFormat.isArchivePath(f.getPath))
+ if (hasArchive) {
+ return inferWithArchives(sparkSession, inputPaths, parsedOptions)
+ }
+
val json: RDD[PortableDataStream] =
JsonDataSource.createBaseRdd(sparkSession, inputPaths, parsedOptions)
val sampled: RDD[PortableDataStream] = JsonUtils.sample(json,
parsedOptions)
@@ -357,6 +270,97 @@ object MultiLineJsonDataSource extends JsonDataSource {
}
}
+ /**
+ * Infers a multi-line JSON schema when at least one input is an archive.
Each archive entry
+ * (streamed via `SupportsArchiveFormat`, never unpacked to disk) and each
loose file is one whole
+ * JSON document, and all feed a single [[JsonInferSchema]] pass -- exactly
as a directory of the
+ * same files would infer. Single-line archive inference does not come here:
the Text data source
+ * reads archives directly, so it flows through
[[TextInputJsonDataSource.infer]] like any
+ * directory read. Corrupt/missing inputs are skipped when the ignore flags
are set (see
+ * [[skipInputOnError]]).
+ */
+ private def inferWithArchives(
+ sparkSession: SparkSession,
+ inputPaths: Seq[FileStatus],
+ parsedOptions: JSONOptions): StructType = {
+ val baseRdd = JsonDataSource.createBaseRdd(sparkSession, inputPaths,
parsedOptions)
+ val encoding = parsedOptions.encoding
+ val ignoreCorruptFiles = parsedOptions.ignoreCorruptFiles
+ val ignoreMissingFiles = parsedOptions.ignoreMissingFiles
+
+ // An archive entry's stream is only valid until the shared cursor
advances, so each document
+ // must be consumed before the next is pulled; `JsonInferSchema.infer`
does.
+ val docs: RDD[InputStream] = baseRdd.flatMap { stream =>
+ val path = new Path(stream.getPath())
+ skipInputOnError(stream.getPath(), ignoreMissingFiles,
ignoreCorruptFiles) {
+ if (SupportsArchiveFormat.isArchivePath(path)) {
+ SupportsArchiveFormat.readArchiveEntries(path,
stream.getConfiguration) { (_, in) =>
+ Iterator.single(in)
+ }
+ } else {
+ Iterator.single(
+
CodecStreams.createInputStreamWithCloseResource(stream.getConfiguration, path))
+ }
+ }
+ }
+
+ SQLExecution.withSQLConfPropagated(sparkSession) {
+ val docParser: (JsonFactory, InputStream) => JsonParser = encoding
+ .map(enc => CreateJacksonParser.inputStream(enc, _: JsonFactory, _:
InputStream))
+ .getOrElse(CreateJacksonParser.inputStream(_: JsonFactory, _:
InputStream))
+ new JsonInferSchema(parsedOptions).infer[InputStream](
+ JsonUtils.sample(docs, parsedOptions), docParser, isReadFile = true)
+ }
+ }
+
+ /**
+ * Builds one input's document iterator, catching a missing/corrupt error
when the ignore flags
+ * are set. `readArchiveEntries` advances to later entries lazily, so a
corrupt later entry throws
+ * on `hasNext`, not at construction; the returned iterator catches both. A
construction failure
+ * skips the whole input; a mid-advance failure keeps the entries already
yielded and skips only
+ * the remainder of the archive.
+ */
+ private def skipInputOnError(
+ inputPath: String,
+ ignoreMissingFiles: Boolean,
+ ignoreCorruptFiles: Boolean)(
+ build: => Iterator[InputStream]): Iterator[InputStream] = {
+ def handle(e: Throwable): Iterator[InputStream] = e match {
+ case e: FileNotFoundException if ignoreMissingFiles =>
+ logWarning(log"Skipped missing input: ${MDC(PATH, inputPath)}", e)
+ Iterator.empty
+ case e: FileNotFoundException => throw e
+ case e @ (_: RuntimeException | _: IOException) if ignoreCorruptFiles =>
+ logWarning(log"Skipped the corrupted input: ${MDC(PATH, inputPath)}",
e)
+ Iterator.empty
+ case NonFatal(e) =>
+ throw QueryExecutionErrors.cannotReadFilesError(
+ e, SparkPath.fromPathString(inputPath).urlEncoded)
+ }
+
+ val underlying =
+ try build
+ catch { case NonFatal(e) => return handle(e) }
+
+ new Iterator[InputStream] {
+ private var delegate = underlying
+ override def hasNext: Boolean =
+ try delegate.hasNext
+ catch {
+ case NonFatal(e) =>
+ // A mid-advance throw reaches neither exhaustion nor close, so
close the failed
+ // iterator here to release its archive stream promptly rather
than at task completion.
+ delegate match {
+ case c: Closeable => try c.close() catch { case NonFatal(_) => }
+ case _ =>
+ }
+ delegate = handle(e)
+ delegate.hasNext
+ }
+ override def next(): InputStream = delegate.next()
+ }
+ }
+
private def dataToInputStream(dataStream: PortableDataStream): InputStream =
{
val path = new Path(dataStream.getPath())
CodecStreams.createInputStreamWithCloseResource(dataStream.getConfiguration,
path)
diff --git
a/sql/core/src/main/scala/org/apache/spark/sql/execution/datasources/v2/csv/CSVTable.scala
b/sql/core/src/main/scala/org/apache/spark/sql/execution/datasources/v2/csv/CSVTable.scala
index 184eb41b6c49..e7b206bd9c58 100644
---
a/sql/core/src/main/scala/org/apache/spark/sql/execution/datasources/v2/csv/CSVTable.scala
+++
b/sql/core/src/main/scala/org/apache/spark/sql/execution/datasources/v2/csv/CSVTable.scala
@@ -46,9 +46,7 @@ case class CSVTable(
columnPruning = sparkSession.sessionState.conf.csvColumnPruning,
sparkSession.sessionState.conf.sessionLocalTimeZone)
- // The DSv2 reader does not route archives to `readArchive` (it calls
`readFile` directly), so
- // archive scans aren't supported here; pass supportsArchiveScan = false
so an archive input
- // keeps failing with UNABLE_TO_INFER_SCHEMA rather than having its raw
bytes parsed as CSV.
+ // The DSv2 scan cannot read archives, so refuse archive inference here.
CSVDataSource(parsedOptions)
.inferSchema(sparkSession, files, parsedOptions, supportsArchiveScan =
false)
}
diff --git
a/sql/core/src/main/scala/org/apache/spark/sql/execution/datasources/v2/json/JsonTable.scala
b/sql/core/src/main/scala/org/apache/spark/sql/execution/datasources/v2/json/JsonTable.scala
index 74095e85a0f6..c635a7d06bed 100644
---
a/sql/core/src/main/scala/org/apache/spark/sql/execution/datasources/v2/json/JsonTable.scala
+++
b/sql/core/src/main/scala/org/apache/spark/sql/execution/datasources/v2/json/JsonTable.scala
@@ -45,8 +45,6 @@ case class JsonTable(
options.asScala.toMap,
sparkSession.sessionState.conf.sessionLocalTimeZone,
sparkSession.sessionState.conf.columnNameOfCorruptRecord)
- // The DSv2 reader calls `readFile` directly and cannot read archives, so
refuse to infer a
- // schema for archive inputs (supportsArchiveScan = false) rather than
mis-reading raw bytes.
JsonDataSource(parsedOptions).inferSchema(
sparkSession, files, parsedOptions, supportsArchiveScan = false)
}
diff --git
a/sql/core/src/main/scala/org/apache/spark/sql/execution/datasources/xml/XmlDataSource.scala
b/sql/core/src/main/scala/org/apache/spark/sql/execution/datasources/xml/XmlDataSource.scala
index ef34711e041e..14116279801d 100644
---
a/sql/core/src/main/scala/org/apache/spark/sql/execution/datasources/xml/XmlDataSource.scala
+++
b/sql/core/src/main/scala/org/apache/spark/sql/execution/datasources/xml/XmlDataSource.scala
@@ -17,7 +17,7 @@
package org.apache.spark.sql.execution.datasources.xml
-import java.io.{ByteArrayInputStream, FileNotFoundException, InputStream,
IOException}
+import java.io.{ByteArrayInputStream, Closeable, FileNotFoundException,
InputStream, IOException}
import java.nio.charset.{Charset, StandardCharsets}
import scala.util.control.NonFatal
@@ -101,15 +101,7 @@ abstract class XmlDataSource extends Serializable with
Logging with SupportsArch
parsedOptions.singleVariantColumn match {
case Some(columnName) => Some(StructType(Array(StructField(columnName,
VariantType))))
case None =>
- // When any input is a tar archive, infer over all inputs in a single
pass -- archive
- // entries are streamed (never unpacked to disk) and tokenized as XML
records alongside any
- // loose files -- so the result matches a directory read of the same
files. XML has no DSv2
- // reader, so this archive scan is always V1.
- val hasArchive = parsedOptions.archiveFormatEnabled &&
- inputPaths.exists(f =>
SupportsArchiveFormat.isArchivePath(f.getPath))
- if (hasArchive) {
- Some(inferWithArchives(sparkSession, inputPaths, parsedOptions))
- } else if (inputPaths.nonEmpty) {
+ if (inputPaths.nonEmpty) {
Some(infer(sparkSession, inputPaths, parsedOptions))
} else {
None
@@ -122,69 +114,6 @@ abstract class XmlDataSource extends Serializable with
Logging with SupportsArch
inputPaths: Seq[FileStatus],
parsedOptions: XmlOptions): StructType
- /**
- * Infers an XML schema when at least one input is a tar archive
(`.tar`/`.tar.gz`/`.tgz`). Every
- * archive entry (streamed through `SupportsArchiveFormat`, never unpacked
to disk) and every
- * loose file is tokenized into records and fed to a single
[[XmlInferSchema]] pass, exactly as a
- * directory of the same files would infer. Tokenization is per-mode so it
matches the scan:
- * multi-line splits the whole stream into `rowTag`-delimited records,
single-line treats each
- * line as a record (mirroring [[readFile]] and JSON's `inferWithArchives`).
- */
- private def inferWithArchives(
- sparkSession: SparkSession,
- inputPaths: Seq[FileStatus],
- parsedOptions: XmlOptions): StructType = {
- val baseRdd = createBaseRdd(sparkSession, inputPaths, parsedOptions)
- val ignoreCorruptFiles = parsedOptions.ignoreCorruptFiles
- val ignoreMissingFiles = parsedOptions.ignoreMissingFiles
-
- // Applies `perEntry` to each input -- an archive entry by entry
(streamed, so only one entry's
- // bytes are in flight at a time), a loose file directly -- skipping a
whole input when it is
- // corrupt/missing and the ignore flags are set.
- def perInput(perEntry: InputStream => Iterator[String]): RDD[String] =
baseRdd.flatMap {
- stream =>
- val path = new Path(stream.getPath())
- try {
- if (SupportsArchiveFormat.isArchivePath(path)) {
- SupportsArchiveFormat.readArchiveEntries(path,
stream.getConfiguration) { (_, in) =>
- perEntry(in)
- }
- } else {
- perEntry(
-
CodecStreams.createInputStreamWithCloseResource(stream.getConfiguration, path))
- }
- } catch {
- case e: FileNotFoundException if ignoreMissingFiles =>
- logWarning("Skipped missing file", e)
- Iterator.empty[String]
- case NonFatal(e) =>
- Utils.getRootCause(e) match {
- case root @ (_: AccessControlException | _:
BlockMissingException) => throw root
- case _: RuntimeException | _: IOException if ignoreCorruptFiles
=>
- logWarning("Skipped the rest of the content in the corrupted
file", e)
- Iterator.empty[String]
- case other => throw other
- }
- }
- }
-
- // Tokenize each input the way this mode's scan reads records, so the
inferred schema matches a
- // directory read: multi-line splits the whole stream into
rowTag-delimited records, single-line
- // treats each line as a record (mirroring
TextInputXmlDataSource.readFile).
- val tokenRDD: RDD[String] = if (parsedOptions.multiLine) {
- perInput(in => StaxXmlParser.tokenizeStream(in, parsedOptions))
- } else {
- val charset = parsedOptions.charset
- perInput(in => lineIterator(in, None).map { line =>
- new String(line.getBytes, 0, line.getLength, charset)
- })
- }
- SQLExecution.withSQLConfPropagated(sparkSession) {
- new XmlInferSchema(parsedOptions,
sparkSession.sessionState.conf.caseSensitiveAnalysis)
- .infer(tokenRDD)
- }
- }
-
protected def createBaseRdd(
sparkSession: SparkSession,
inputPaths: Seq[FileStatus],
@@ -358,6 +287,12 @@ object MultiLineXmlDataSource extends XmlDataSource {
inputPaths: Seq[FileStatus],
parsedOptions: XmlOptions): StructType = {
+ val hasArchive = parsedOptions.archiveFormatEnabled &&
+ inputPaths.exists(f => SupportsArchiveFormat.isArchivePath(f.getPath))
+ if (hasArchive) {
+ return inferWithArchives(sparkSession, inputPaths, parsedOptions)
+ }
+
if (!parsedOptions.useLegacyXMLParser) {
return inferOptimized(sparkSession, inputPaths, parsedOptions)
}
@@ -418,4 +353,91 @@ object MultiLineXmlDataSource extends XmlDataSource {
schema
}
}
+
+ /**
+ * Infers a multi-line XML schema when at least one input is an archive.
Every archive entry
+ * (streamed through `SupportsArchiveFormat`, never unpacked to disk) and
every loose file is
+ * tokenized into `rowTag`-delimited records and fed to a single
[[XmlInferSchema]] pass, exactly
+ * as a directory of the same files would infer. Single-line archive
inference does not come here:
+ * the Text data source reads archives directly, so it flows through
+ * [[TextInputXmlDataSource.infer]] like any directory read. Corrupt/missing
inputs are skipped
+ * when the ignore flags are set (see [[skipInputOnError]]). Uses the legacy
tokenizer because the
+ * optimized parser re-opens its input, which a single-use archive entry
stream does not support.
+ */
+ private def inferWithArchives(
+ sparkSession: SparkSession,
+ inputPaths: Seq[FileStatus],
+ parsedOptions: XmlOptions): StructType = {
+ val baseRdd = createBaseRdd(sparkSession, inputPaths, parsedOptions)
+ val ignoreCorruptFiles = parsedOptions.ignoreCorruptFiles
+ val ignoreMissingFiles = parsedOptions.ignoreMissingFiles
+
+ val tokenRDD: RDD[String] = baseRdd.flatMap { stream =>
+ val path = new Path(stream.getPath())
+ skipInputOnError(ignoreMissingFiles, ignoreCorruptFiles) {
+ if (SupportsArchiveFormat.isArchivePath(path)) {
+ SupportsArchiveFormat.readArchiveEntries(path,
stream.getConfiguration) { (_, in) =>
+ StaxXmlParser.tokenizeStream(in, parsedOptions)
+ }
+ } else {
+ StaxXmlParser.tokenizeStream(
+
CodecStreams.createInputStreamWithCloseResource(stream.getConfiguration, path),
+ parsedOptions)
+ }
+ }
+ }
+ SQLExecution.withSQLConfPropagated(sparkSession) {
+ new XmlInferSchema(parsedOptions,
sparkSession.sessionState.conf.caseSensitiveAnalysis)
+ .infer(tokenRDD)
+ }
+ }
+
+ /**
+ * Builds one input's token iterator, catching a missing/corrupt error when
the ignore flags are
+ * set. `readArchiveEntries` advances to later entries lazily, so a corrupt
later entry throws on
+ * `hasNext`, not at construction; the returned iterator catches both. A
construction failure
+ * skips the whole input; a mid-advance failure keeps the records already
yielded and skips only
+ * the remainder of the archive. Access/block errors are always rethrown
(unwrapped), matching the
+ * non-archive read path.
+ */
+ private def skipInputOnError(
+ ignoreMissingFiles: Boolean,
+ ignoreCorruptFiles: Boolean)(
+ build: => Iterator[String]): Iterator[String] = {
+ def handle(e: Throwable): Iterator[String] = e match {
+ case e: FileNotFoundException if ignoreMissingFiles =>
+ logWarning("Skipped missing file", e)
+ Iterator.empty[String]
+ case NonFatal(e) =>
+ Utils.getRootCause(e) match {
+ case root @ (_: AccessControlException | _: BlockMissingException)
=> throw root
+ case _: RuntimeException | _: IOException if ignoreCorruptFiles =>
+ logWarning("Skipped the rest of the content in the corrupted
file", e)
+ Iterator.empty[String]
+ case other => throw other
+ }
+ }
+
+ val underlying =
+ try build
+ catch { case NonFatal(e) => return handle(e) }
+
+ new Iterator[String] {
+ private var delegate = underlying
+ override def hasNext: Boolean =
+ try delegate.hasNext
+ catch {
+ case NonFatal(e) =>
+ // A mid-advance throw reaches neither exhaustion nor close, so
close the failed
+ // iterator here to release its archive stream promptly rather
than at task completion.
+ delegate match {
+ case c: Closeable => try c.close() catch { case NonFatal(_) => }
+ case _ =>
+ }
+ delegate = handle(e)
+ delegate.hasNext
+ }
+ override def next(): String = delegate.next()
+ }
+ }
}
diff --git
a/sql/core/src/test/scala/org/apache/spark/sql/execution/datasources/ArchiveReadSuiteBase.scala
b/sql/core/src/test/scala/org/apache/spark/sql/execution/datasources/ArchiveReadSuiteBase.scala
index ae64f5b50293..943eb637e8fa 100644
---
a/sql/core/src/test/scala/org/apache/spark/sql/execution/datasources/ArchiveReadSuiteBase.scala
+++
b/sql/core/src/test/scala/org/apache/spark/sql/execution/datasources/ArchiveReadSuiteBase.scala
@@ -105,6 +105,19 @@ trait ArchiveReadSuiteBase extends QueryTest with
SharedSparkSession {
protected def corruptEntryBytes: Array[Byte] =
s"This is not a valid $format file".getBytes("UTF-8")
+ /**
+ * Whether [[writeArchiveFailingAfterFirstEntry]] is supported, gating the
mid-advance
+ * corrupt-skip regression test. From the container trait.
+ */
+ protected def supportsMidAdvanceFailure: Boolean
+
+ /**
+ * Writes an archive at `dest` whose first entry (`firstEntry`) reads
cleanly but which then fails
+ * while advancing to a later entry. From the container trait.
+ */
+ protected def writeArchiveFailingAfterFirstEntry(
+ dest: File, firstEntry: (String, Array[Byte])): Unit
+
/** An archive extension whose reader fails on corrupt bytes (used by the
corrupt-file tests). */
protected def corruptArchiveExtension: String
diff --git
a/sql/core/src/test/scala/org/apache/spark/sql/execution/datasources/CSVArchiveReadBase.scala
b/sql/core/src/test/scala/org/apache/spark/sql/execution/datasources/CSVArchiveReadBase.scala
index b3ab43fcd19c..3665f857edd6 100644
---
a/sql/core/src/test/scala/org/apache/spark/sql/execution/datasources/CSVArchiveReadBase.scala
+++
b/sql/core/src/test/scala/org/apache/spark/sql/execution/datasources/CSVArchiveReadBase.scala
@@ -131,11 +131,8 @@ trait CSVArchiveReadBase extends ArchiveReadSuiteBase {
}
test("CSV: the DSv2 path refuses to infer a schema for an archive
(UNABLE_TO_INFER_SCHEMA)") {
- // Archive scanning is wired into the V1 file source only, so the DSv2
reader cannot read
- // archives. On the V2 path inference must keep returning None for an
archive input -- raising
- // UNABLE_TO_INFER_SCHEMA -- rather than inferring a schema and letting
the V2 scan parse the
- // raw archive bytes as CSV. Forcing csv off the V1 source list routes the
read through
- // CSVTable.
+ // Forcing csv off the V1 source list routes the archive read through the
DSv2 CSVTable, which
+ // cannot read archives and must fail with UNABLE_TO_INFER_SCHEMA, not
parse raw bytes.
withArchiveFile() { archive =>
writeArchive(archive, Seq(entryName(0) -> encodeFile(sampleDf((1,
"Alice"), (2, "Bob")))))
withSQLConf(SQLConf.USE_V1_SOURCE_LIST.key -> "") {
diff --git
a/sql/core/src/test/scala/org/apache/spark/sql/execution/datasources/JSONArchiveReadBase.scala
b/sql/core/src/test/scala/org/apache/spark/sql/execution/datasources/JSONArchiveReadBase.scala
index 24a187380535..70de9e5e6317 100644
---
a/sql/core/src/test/scala/org/apache/spark/sql/execution/datasources/JSONArchiveReadBase.scala
+++
b/sql/core/src/test/scala/org/apache/spark/sql/execution/datasources/JSONArchiveReadBase.scala
@@ -184,11 +184,30 @@ trait JSONArchiveReadBase extends ArchiveReadSuiteBase {
schema = corruptSchema)
}
+ if (supportsMidAdvanceFailure) {
+ test("JSON: multiLine inference keeps entries read before a mid-advance
failure " +
+ "(ignoreCorruptFiles)") {
+ // Entry 0 is read, then advancing to a later entry throws (not at
open). A whole-archive drop
+ // would lose entry 0's `extra`; aborting the traversal would lose the
sibling file's `later`.
+ val opts = Map("multiLine" -> "true")
+ withArchiveFile() { archive =>
+ writeArchiveFailingAfterFirstEntry(archive,
+ entryName(0) -> jsonBytes("{\n \"id\": 1,\n \"name\": \"Alice\",\n
\"extra\": 9\n}"))
+ Files.write(new File(archive.getParentFile,
s"later.$fileExtension").toPath,
+ jsonBytes("{\n \"id\": 2,\n \"name\": \"Bob\",\n \"later\":
7\n}"))
+ withSQLConf(SQLConf.IGNORE_CORRUPT_FILES.key -> "true") {
+ val schema =
inferredSchema(Seq(archive.getParentFile.getCanonicalPath), opts)
+ assert(schema.fieldNames.toSet == Set("id", "name", "extra",
"later"),
+ "expected `extra` (pre-failure entry) and `later` (sibling file)
in the inferred " +
+ s"schema after the mid-advance skip, got $schema")
+ }
+ }
+ }
+ }
+
test("JSON: the DSv2 path refuses to infer a schema for an archive
(UNABLE_TO_INFER_SCHEMA)") {
- // Archive scanning is wired into the v1 file source only, so the DSv2
reader cannot read
- // archives. On the v2 path inference must keep returning None for an
archive input -- raising
- // UNABLE_TO_INFER_SCHEMA -- rather than inferring a schema the v2 scan
would then mis-read as
- // raw archive bytes. Forcing json off the v1 source list routes the read
through JsonTable.
+ // Forcing json off the v1 source list routes the archive read through the
DSv2 JsonTable, which
+ // cannot read archives and must fail with UNABLE_TO_INFER_SCHEMA, not
parse raw bytes.
withArchiveFile() { archive =>
writeArchive(archive, Seq(entryName(0) -> encodeFile(sampleDf((1,
"Alice"), (2, "Bob")))))
withSQLConf(SQLConf.USE_V1_SOURCE_LIST.key -> "") {
diff --git
a/sql/core/src/test/scala/org/apache/spark/sql/execution/datasources/SevenZArchiveReadBase.scala
b/sql/core/src/test/scala/org/apache/spark/sql/execution/datasources/SevenZArchiveReadBase.scala
index efc6c280bfb6..1c77964e6a5f 100644
---
a/sql/core/src/test/scala/org/apache/spark/sql/execution/datasources/SevenZArchiveReadBase.scala
+++
b/sql/core/src/test/scala/org/apache/spark/sql/execution/datasources/SevenZArchiveReadBase.scala
@@ -68,4 +68,12 @@ trait SevenZArchiveTestUtils {
protected def writeCorruptArchive(dest: File): Unit =
Files.write(dest.toPath, "this is not a valid 7z archive, just some random
bytes"
.getBytes(StandardCharsets.UTF_8))
+
+ // 7z validates its whole index when the archive is opened, so this helper
cannot construct a
+ // failure that occurs only while advancing to another entry.
+ protected def supportsMidAdvanceFailure: Boolean = false
+
+ protected def writeArchiveFailingAfterFirstEntry(
+ dest: File, firstEntry: (String, Array[Byte])): Unit =
+ throw new UnsupportedOperationException("7z cannot fail while advancing to
a later entry")
}
diff --git
a/sql/core/src/test/scala/org/apache/spark/sql/execution/datasources/TarArchiveTestUtils.scala
b/sql/core/src/test/scala/org/apache/spark/sql/execution/datasources/TarArchiveTestUtils.scala
index 7f1d48e8143b..a31c2b187eac 100644
---
a/sql/core/src/test/scala/org/apache/spark/sql/execution/datasources/TarArchiveTestUtils.scala
+++
b/sql/core/src/test/scala/org/apache/spark/sql/execution/datasources/TarArchiveTestUtils.scala
@@ -17,7 +17,7 @@
package org.apache.spark.sql.execution.datasources
-import java.io.{File, FileOutputStream, OutputStream}
+import java.io.{ByteArrayOutputStream, File, FileOutputStream, OutputStream}
import java.nio.charset.StandardCharsets
import java.nio.file.Files
import java.util.Locale
@@ -62,4 +62,34 @@ trait TarArchiveTestUtils {
protected def writeCorruptArchive(dest: File): Unit =
Files.write(dest.toPath, "this is not a valid gzip-compressed tar archive"
.getBytes(StandardCharsets.UTF_8))
+
+ protected def supportsMidAdvanceFailure: Boolean = true
+
+ /**
+ * Writes a plain `.tar` (uncompressed, so the first entry survives
truncation) with `firstEntry`
+ * intact followed by a second entry whose header is cut short, so
`getNextEntry` throws while
+ * advancing to it.
+ */
+ protected def writeArchiveFailingAfterFirstEntry(
+ dest: File, firstEntry: (String, Array[Byte])): Unit = {
+ require(firstEntry._1.getBytes(StandardCharsets.UTF_8).length <= 100,
+ "the first entry's name must fit the 100-byte ustar name field so its
header is a single " +
+ s"512-byte block, otherwise the truncation offset below shifts; got
'${firstEntry._1}'")
+ val buf = new ByteArrayOutputStream()
+ val out = new TarArchiveOutputStream(buf)
+ Seq(firstEntry, ("part-later.bin", ("x" *
4096).getBytes(StandardCharsets.UTF_8))).foreach {
+ case (entryName, bytes) =>
+ val entry = new TarArchiveEntry(entryName)
+ entry.setSize(bytes.length.toLong)
+ out.putArchiveEntry(entry)
+ out.write(bytes)
+ out.closeArchiveEntry()
+ }
+ out.finish()
+ out.close()
+ // Keep the first entry (512-byte header + block-aligned body) plus half
of the second entry's
+ // 512-byte header, so `getNextEntry` hits a partial header while
advancing.
+ val firstBlocks = 512 + ((firstEntry._2.length + 511) / 512) * 512
+ Files.write(dest.toPath, buf.toByteArray.take(firstBlocks + 256))
+ }
}
diff --git
a/sql/core/src/test/scala/org/apache/spark/sql/execution/datasources/XMLArchiveReadBase.scala
b/sql/core/src/test/scala/org/apache/spark/sql/execution/datasources/XMLArchiveReadBase.scala
index c5e956d4cd38..ffa5484b3194 100644
---
a/sql/core/src/test/scala/org/apache/spark/sql/execution/datasources/XMLArchiveReadBase.scala
+++
b/sql/core/src/test/scala/org/apache/spark/sql/execution/datasources/XMLArchiveReadBase.scala
@@ -21,6 +21,7 @@ import java.io.File
import java.nio.charset.StandardCharsets
import java.nio.file.Files
+import org.apache.spark.sql.internal.SQLConf
import org.apache.spark.sql.types.StringType
/**
@@ -132,6 +133,27 @@ trait XMLArchiveReadBase extends ArchiveReadSuiteBase {
extraOptions = Map("multiLine" -> "true"),
schema = corruptSchema)
}
+
+ if (supportsMidAdvanceFailure) {
+ test("XML: multiLine inference keeps records read before a mid-advance
failure " +
+ "(ignoreCorruptFiles)") {
+ // Entry 0 is read, then advancing to a later entry throws (not at
open). A whole-archive drop
+ // would lose entry 0's `extra`; aborting the traversal would lose the
sibling file's `later`.
+ val opts = Map("multiLine" -> "true")
+ withArchiveFile() { archive =>
+ writeArchiveFailingAfterFirstEntry(archive, entryName(0) ->
+
xmlBytes("<rows><row><id>1</id><name>Alice</name><extra>9</extra></row></rows>"))
+ Files.write(new File(archive.getParentFile,
s"later.$fileExtension").toPath,
+
xmlBytes("<rows><row><id>2</id><name>Bob</name><later>7</later></row></rows>"))
+ withSQLConf(SQLConf.IGNORE_CORRUPT_FILES.key -> "true") {
+ val schema =
inferredSchema(Seq(archive.getParentFile.getCanonicalPath), opts)
+ assert(schema.fieldNames.toSet == Set("id", "name", "extra",
"later"),
+ "expected `extra` (pre-failure entry) and `later` (sibling file)
in the inferred " +
+ s"schema after the mid-advance skip, got $schema")
+ }
+ }
+ }
+ }
}
class XMLTarArchiveReadSuite
diff --git
a/sql/core/src/test/scala/org/apache/spark/sql/execution/datasources/ZipArchiveTestUtils.scala
b/sql/core/src/test/scala/org/apache/spark/sql/execution/datasources/ZipArchiveTestUtils.scala
index cf9e42262a7b..51282d1e856d 100644
---
a/sql/core/src/test/scala/org/apache/spark/sql/execution/datasources/ZipArchiveTestUtils.scala
+++
b/sql/core/src/test/scala/org/apache/spark/sql/execution/datasources/ZipArchiveTestUtils.scala
@@ -57,4 +57,13 @@ trait ZipArchiveTestUtils {
protected def writeCorruptArchive(dest: File): Unit =
Files.write(dest.toPath, "this is not a valid zip archive, just some
random bytes"
.getBytes(StandardCharsets.UTF_8))
+
+ // Zip can't throw while *advancing*: a cut header reads as clean EOF, and a
cut body only throws
+ // when the entry's bytes are read -- which a lazy reader (JSON) defers
downstream. Tar is used
+ // for the mid-advance regression test instead.
+ protected def supportsMidAdvanceFailure: Boolean = false
+
+ protected def writeArchiveFailingAfterFirstEntry(
+ dest: File, firstEntry: (String, Array[Byte])): Unit =
+ throw new UnsupportedOperationException("zip cannot force a throw while
advancing to an entry")
}
---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]