HyukjinKwon commented on code in PR #59435:
URL: https://github.com/apache/spark/pull/59435#discussion_r4273571562


##########
sql/core/src/test/scala/org/apache/spark/sql/execution/datasources/csv/CSVSuite.scala:
##########
@@ -4303,6 +4305,165 @@ abstract class CSVSuite
       }
     }
   }
+
+  // Writes `data` to `file` compressed with `codec` (a short name; "" means 
no compression), using
+  // Spark's built-in Zstandard codec for zstd (which Hadoop may not be 
compiled with) and Hadoop's
+  // CompressionCodecFactory otherwise. The file name intentionally does NOT 
carry the codec's
+  // extension, so extension-based detection cannot pick the right codec and 
the
+  // `decompressionCodec` option is what makes the read succeed.
+  private def writeCompressed(file: File, data: String, codec: String): Unit = 
{
+    val codecFactory = new 
CompressionCodecFactory(spark.sessionState.newHadoopConf())
+    val os = new FileOutputStream(file)
+    val out = codec match {
+      case "" => os
+      case "zstd" | "zstandard" => new 
ZStdCompressionCodec(sparkConf).compressedOutputStream(os)
+      case name => codecFactory.getCodecByName(name).createOutputStream(os)
+    }
+    out.write(data.getBytes(StandardCharsets.UTF_8))
+    out.close()
+  }
+
+  test("SPARK-60217 decompressionCodec option overrides file name extension") {
+    val numRecord = 100
+    val records = (1 to numRecord).map(i => (i, s"value_$i"))
+    // Every read-supported codec, including zstd/zstandard which the writers 
do not support.
+    val codecs = HadoopCompressionCodec.values().map(_.lowerCaseName())
+      .filterNot(c => c == HadoopCompressionCodec.NONE.lowerCaseName() ||
+        c == HadoopCompressionCodec.UNCOMPRESSED.lowerCaseName()) ++ 
Seq("zstd", "zstandard")
+    for (multiLine <- Seq(true, false); codec <- codecs) {
+      withTempDir { dir =>
+        val data = ("id,value" +: records.map { case (id, v) => s"$id,$v" 
}).mkString("\n")
+        // Misleading `.csv` name (no codec suffix): extension-based detection 
would read it as
+        // plain text and fail. The `decompressionCodec` option forces the 
correct codec.
+        val file = new File(dir, "data.csv")
+        writeCompressed(file, data, codec)
+
+        val df = spark.read
+          .option("decompressionCodec", codec)
+          .option("header", "true")
+          .option("inferSchema", "true")
+          .option("multiLine", multiLine.toString)
+          .csv(file.getCanonicalPath)
+        checkAnswer(df, records.map { case (id, v) => Row(id, v) })
+      }
+    }
+  }
+
+  test("SPARK-60217 decompressionCodec option decides whether a file is 
split") {

Review Comment:
   **Nit (P3):** This is the only V1 CSV split test, and it cannot fail if the 
new term in `CSVFileFormat.isSplitable` is reverted: bzip2 on a `.csv` name is 
splittable by extension anyway, and the multiLine case short-circuits before 
the term.
   
   The other formats have the same gap. `JsonSuite` (V1 and V2) has no 
partition-count or unsplittable-reason assertion. Every `XmlSuite` test uses 
the default `multiLine=true`, so `XmlFileFormat`'s new term never runs.
   
   Cases like the `TextSuite` split table for these formats would close the gap.
   
   **Verification:**
   - **Regression:** For CSV V1, JSON V1/V2 and line-mode XML, a forced 
non-splittable codec yields exactly one partition with the complete answer, and 
forced `none` on a .gz-named plain file yields multiple partitions with the 
complete answer.



##########
sql/core/src/main/scala/org/apache/spark/sql/execution/datasources/FileFormat.scala:
##########
@@ -482,6 +483,26 @@ object FileFormat {
 abstract class TextBasedFileFormat extends FileFormat with SessionStateHelper {
   private var codecFactory: CompressionCodecFactory = _
 
+  /**
+   * Forces the `decompressionCodec` read option, when set, onto `hadoopConf` 
before the format's
+   * [[buildReader]] broadcasts it, so the readers decompress every file with 
that codec instead of
+   * inferring it from the file name extension
+   * (see [[CompressionCodecs.setReadCodecConfiguration]]).
+   */
+  override def buildReaderWithPartitionValues(
+      sparkSession: SparkSession,
+      dataSchema: StructType,
+      partitionSchema: StructType,
+      requiredSchema: StructType,
+      filters: Seq[Filter],
+      options: Map[String, String],
+      hadoopConf: Configuration): PartitionedFile => Iterator[InternalRow] = {
+    CompressionCodecs.setReadCodecConfiguration(

Review Comment:
   **Nit (P3):** This override forces `decompressionCodec` for every 
`TextBasedFileFormat` subclass, not only CSV, JSON, text and XML. mllib's 
`LibSVMFileFormat` inherits it but keeps the extension-based `isSplitable`.
   
   For example, `spark.read.format("libsvm").option("decompressionCodec", 
"gzip").load(path)` on a gzip file named `data.libsvm` that spans several 
splits fails in `HadoopLineRecordReader` with `Cannot seek in GzipCodec 
compressed stream`. With `ignoreCorruptFiles=true`, every split after the first 
is skipped, so rows go missing without an error.
   
   libsvm's numFeatures inference also honors the option today: 
`MLUtils.parseLibSVMFile` reads through `TextFileFormat` with the same options. 
The `FileSourceOptions` scaladoc says only the four formats honor the option. 
CSV, JSON, text and XML reads are not affected.
   
   **Recommended change:** Make forcing `decompressionCodec` an explicit opt-in 
of the CSV, JSON, text and XML file formats, for example an overridable 
TextBasedFileFormat hook that defaults to off. Other TextBasedFileFormat 
subclasses, including LibSVMFileFormat, then get the internal key unset and 
keep merge-target reading and splitting. Also drop `decompressionCodec` from 
the options LibSVMFileFormat.inferSchema passes to MLUtils.parseLibSVMFile, so 
numFeatures inference ignores it as well. Add libsvm data source regression 
tests showing the option does not affect libsvm reads, with and without 
numFeatures.
   
   **Why this works:** The base buildReaderWithPartitionValues passes the 
parsed codec to setReadCodecConfiguration only when the concrete format opts 
in. Otherwise it passes None, which unsets the key, so a format that forces the 
codec is always one that also applies decompressionCodecSplittable in its 
isSplitable. LibSVMFileFormat.inferSchema removes the option before delegating 
to TextFileFormat, so no libsvm read route forces the codec.
   
   **Scope:** Restrict codec forcing in the V1 text-based base class to the 
four formats that already own the matching split precedence. Keep libsvm's 
TextFileFormat-based schema inference from re-applying the option, and pin 
libsvm behavior with regression tests.
   
   **Compatibility:** Forced decompression and split precedence for the CSV, 
JSON, text and XML sources on DSv1 and DSv2.
   
   **Risks:** A supporting format that forgets to opt in silently ignores the 
option. The four formats must opt in, and the existing per-format decode tests 
catch a missed opt-in. A future non-supporting format that delegates inference 
to a supporting format with raw user options would re-open the second route; 
the libsvm change documents that pattern.
   
   **Constraints:** Keep the DSv2 TextBasedFileScan path unchanged. All its 
subclasses already support the option.
   
   **Success:** spark.read.format("libsvm") with `decompressionCodec` set 
reads, infers numFeatures and splits files exactly as without the option, 
whether or not numFeatures is given. CSV, JSON, text and XML reads keep 
honoring the forced codec and its split precedence.
   
   **Verification:**
   - **Regression:** With `decompressionCodec` set to a non-splittable codec, a 
libsvm read of a multi-split plain file returns all rows with the same 
partitioning as without the option, both when numFeatures is given and when it 
is inferred, and fails with neither a cannot-seek nor a decompression error.
   - **Compatibility:** The existing forced-codec decode and split tests for 
CSV, JSON, text and XML still pass, showing the four formats still opt in.



##########
docs/sql-data-sources-csv.md:
##########
@@ -297,6 +297,12 @@ Data source options of CSV can be set via:
     <td>Compression codec to use when saving to file. This can be one of the 
known case-insensitive shorten names (<code>none</code>, <code>bzip2</code>, 
<code>gzip</code>, <code>lz4</code>, <code>snappy</code> and 
<code>deflate</code>). CSV built-in functions ignore this option.</td>
     <td>write</td>
   </tr>
+  <tr>
+    <td><code>decompressionCodec</code></td>
+    <td>(none)</td>
+    <td>Compression codec used to decompress the input files, instead of 
inferring it from each file name extension. This can be one of the known 
case-insensitive short names (<code>none</code>, <code>uncompressed</code>, 
<code>bzip2</code>, <code>deflate</code>, <code>gzip</code>, <code>lz4</code>, 
<code>snappy</code>, <code>zstd</code> and <code>zstandard</code>) or a fully 
qualified Hadoop <code>CompressionCodec</code> class name. <code>none</code> 
and <code>uncompressed</code> read the files as-is, even when the file name 
carries a compression extension. The codec applies to every file read; setting 
it when the input includes an archive file (<code>.tar</code>, 
<code>.tar.gz</code>, <code>.tgz</code>, <code>.zip</code> or <code>.7z</code>) 
is not supported and fails the query. Unlike the write option 
<code>compression</code>, which is ignored on read, it does not affect writes. 
CSV built-in functions ignore this option.</td>

Review Comment:
   **Nit (P3):** This archive sentence is unconditional, and so are the ones in 
the JSON, text and XML pages. But `checkNoDecompressionCodec` only runs inside 
the `archiveFormatEnabled && isArchivePath` branch, and 
`spark.sql.files.archive.reader.enabled` defaults to false.
   
   With the default config, `spark.read.option("decompressionCodec", 
"gzip").csv("in.tar.gz")` does not fail: the tar stream is gunzipped and parsed 
as CSV rows. Could the four rows say that the rejection applies when archive 
reading is enabled?
   
   **Verification:**
   - **Inspection:** Each updated doc row names the archive-reading condition 
and no longer promises an unconditional failure.



##########
sql/core/src/main/scala/org/apache/spark/sql/execution/datasources/SupportsArchiveFormat.scala:
##########
@@ -216,6 +229,8 @@ object SupportsArchiveFormat {
    * `(entry, stream)` pairs.
    */
   private def openArchiveStream(path: Path, conf: Configuration): 
ArchiveEntries = {
+    // Backstop for paths that open archives without planning a scan (e.g. 
schema inference).
+    checkNoDecompressionCodec(path, 
Option(conf.get(HadoopCodecStreams.DECOMPRESSION_CODEC_CONF)))

Review Comment:
   **Nit (P3):** On the archive-aware schema inference paths (CSV 
`inferSchema`'s archive branch and multiLine JSON and XML `inferWithArchives`), 
this backstop is the only check, and it runs inside an inference task. 
`inferWithArchives`/`skipInputOnError` then catch it:
   
   - with `ignoreCorruptFiles=true`, the archive is logged as corrupt and 
dropped from inference;
   - otherwise, the job fails with `FAILED_READ_FILE` wrapping the cause.
   
   Both differ from the top-level driver-side 
`UNSUPPORTED_FEATURE.DECOMPRESSION_CODEC_FOR_ARCHIVE` described in the PR and 
in `checkNoDecompressionCodec`'s scaladoc. For example, with archive reading 
enabled, `spark.read.option("multiLine", true).option("decompressionCodec", 
"gzip").json(dirWithTarGz)` without a schema takes this path. The shared 
archive tests always read with `.schema(readSchema)`, so it is never exercised.
   
   The inference entry points already know on the driver whether an input is an 
archive, so checking there would keep the error consistent. Only the opt-in 
archive reader is affected.
   
   **Verification:**
   - **Regression:** Reading an archive with `decompressionCodec` and no user 
schema raises the top-level UNSUPPORTED_FEATURE.DECOMPRESSION_CODEC_FOR_ARCHIVE 
error, with ignoreCorruptFiles both true and false, for every archive-capable 
format that accepts the option.



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