dongjoon-hyun commented on code in PR #59542:
URL: https://github.com/apache/spark/pull/59542#discussion_r4315099719
##########
sql/core/src/main/java/org/apache/spark/sql/execution/datasources/parquet/VectorizedColumnReader.java:
##########
@@ -146,21 +152,31 @@ public VectorizedColumnReader(
int96RebaseMode,
int96RebaseTz);
- DictionaryPage dictionaryPage = pageReader.readDictionaryPage();
- if (dictionaryPage != null) {
- try {
- this.dictionary =
dictionaryPage.getEncoding().initDictionary(descriptor, dictionaryPage);
- this.isCurrentPageDictionaryEncoded = true;
- } catch (IOException e) {
- throw new IOException("could not decode the dictionary for " +
descriptor, e);
- } catch (ParquetDecodingException e) {
- throw new ParquetDecodingException(
- "could not decode the dictionary for " + descriptor + ": " +
e.getMessage(), e);
+ Dictionary dict = null;
+ try {
+ // This decompresses the page eagerly for some codecs, e.g. ZSTD.
+ DictionaryPage dictionaryPage = pageReader.readDictionaryPage();
+ if (dictionaryPage != null) {
+ dict = dictionaryPage.getEncoding().initDictionary(descriptor,
dictionaryPage);
}
- } else {
- this.dictionary = null;
- this.isCurrentPageDictionaryEncoded = false;
+ } catch (IOException e) {
+ throw new IOException("could not decode the dictionary for " +
descriptor, e);
+ } catch (ParquetDecodingException e) {
+ throw new ParquetDecodingException(
+ "could not decode the dictionary for " + descriptor + ": " +
e.getMessage(), e);
+ } catch (IndexOutOfBoundsException | NegativeArraySizeException |
Review Comment:
Minor: the third bullet of item 5 of my second review (on L373) is still
open, and it applies to data pages too. `compression=lz4` maps to Hadoop's
`Lz4Codec`, which parquet-java decompresses lazily (`CodecFactory` copies
eagerly only for ZSTD and LZ4_RAW). So a page whose LZ4 block is corrupt is
decompressed inside `initDictionary` at L160, or by `toInputStream()` at L491
and L518. Hadoop's `Lz4Decompressor` passes lz4-java's `LZ4Exception`, a
`RuntimeException`, through, so it gets past the catches here and at L497-L502
and L520-L525, and the read fails without the column.
The description says that LZ4_RAW differs "because parquet-java does not
turn it into an `IOException` as it does for the other codecs", which does not
hold for LZ4. Could we list LZ4 next to LZ4_RAW in the "not covered" list and
drop "as it does for the other codecs"?
##########
sql/core/src/main/java/org/apache/spark/sql/execution/datasources/parquet/VectorizedColumnReader.java:
##########
@@ -146,21 +152,31 @@ public VectorizedColumnReader(
int96RebaseMode,
int96RebaseTz);
- DictionaryPage dictionaryPage = pageReader.readDictionaryPage();
- if (dictionaryPage != null) {
- try {
- this.dictionary =
dictionaryPage.getEncoding().initDictionary(descriptor, dictionaryPage);
- this.isCurrentPageDictionaryEncoded = true;
- } catch (IOException e) {
- throw new IOException("could not decode the dictionary for " +
descriptor, e);
- } catch (ParquetDecodingException e) {
- throw new ParquetDecodingException(
- "could not decode the dictionary for " + descriptor + ": " +
e.getMessage(), e);
+ Dictionary dict = null;
+ try {
+ // This decompresses the page eagerly for some codecs, e.g. ZSTD.
+ DictionaryPage dictionaryPage = pageReader.readDictionaryPage();
+ if (dictionaryPage != null) {
+ dict = dictionaryPage.getEncoding().initDictionary(descriptor,
dictionaryPage);
}
- } else {
- this.dictionary = null;
- this.isCurrentPageDictionaryEncoded = false;
+ } catch (IOException e) {
+ throw new IOException("could not decode the dictionary for " +
descriptor, e);
+ } catch (ParquetDecodingException e) {
+ throw new ParquetDecodingException(
+ "could not decode the dictionary for " + descriptor + ": " +
e.getMessage(), e);
+ } catch (IndexOutOfBoundsException | NegativeArraySizeException |
+ UnsupportedOperationException e) {
+ // Reading the dictionary page decompresses it, for some codecs only
when initDictionary
+ // reads its bytes, and initDictionary parses it. A corrupted page or
page header makes
+ // them throw these: an IndexOutOfBoundsException when a BINARY
dictionary ends within the
+ // length of an entry, a NegativeArraySizeException for a negative size,
and an
+ // UnsupportedOperationException for an encoding that has no dictionary.
An entry that
+ // claims more bytes than the page has is not detected here
(SPARK-60316).
+ throw new ParquetDecodingException(
+ "could not decode the dictionary for " + descriptor + ": " + e, e);
}
+ this.dictionary = dict;
+ this.isCurrentPageDictionaryEncoded = dict != null;
if (pageReader.getTotalValueCount() == 0) {
throw new IOException("totalValueCount == 0");
Review Comment:
Nit: this is now the only error in the constructor that does not name the
column. It is older than this PR, but a corrupt footer that sets `num_values =
0` for one column chunk of a non-empty row group reaches it: parquet-java then
reads no page of that chunk, and the read fails with only `totalValueCount ==
0`. Could we add the column here as well, e.g. `throw new
IOException("totalValueCount == 0 in col " + descriptor);`?
##########
sql/core/src/test/scala/org/apache/spark/sql/execution/datasources/parquet/ParquetVectorizedSuite.scala:
##########
@@ -887,6 +935,37 @@ class ParquetVectorizedSuite extends ParquetTest with
SharedSparkSession {
columnWriterStore.flush()
}
+
+ /**
+ * Reads a row group of the optional column `a` of type `columnType`, with
an optional
+ * dictionary page and one data page of `valueCount` values, and returns the
error of reading
+ * it with the vectorized reader.
+ */
+ private def readCorruptedPage(
+ columnType: String,
+ dictionaryPage: Option[DictionaryPage],
+ data: Array[Byte],
+ valueCount: Int,
+ valuesEncoding: Encoding): ParquetDecodingException = {
+ val parquetSchema =
+ MessageTypeParser.parseMessageType(s"message root { optional $columnType
a; }")
+ val ty = parquetSchema.asGroupType().getType("a").asPrimitiveType()
+ val cd = new ColumnDescriptor(Array("a"), ty, 0, 1)
+ val memPageStore = new MemPageStore(valueCount)
+ val pageWriter = memPageStore.getPageWriter(cd)
+ dictionaryPage.foreach(pageWriter.writeDictionaryPage)
+ pageWriter.writePage(BytesInput.from(data), valueCount, valueCount,
+ Statistics.createStats(ty), Encoding.RLE, Encoding.RLE, valuesEncoding)
+ val recordReader = new VectorizedParquetRecordReader(
+ DateTimeUtils.getZoneId("EST"), "CORRECTED", "UTC", "CORRECTED", "UTC",
true, valueCount)
+ try {
+ recordReader.initialize(parquetSchema, parquetSchema,
+ TestParquetRowGroupReader(Seq(TestPageReadStore(memPageStore,
Seq(0L)))), valueCount)
+ intercept[ParquetDecodingException](recordReader.nextKeyValue())
+ } finally {
+ recordReader.close()
+ }
+ }
Review Comment:
Nit: moving `readCorruptedPage` here left two blank lines above its Scaladoc
(L937-L938) and none between its closing brace and `checkAnswer` (L968-L969).
Could we move the blank line from L938 to after L968? Also,
`ParquetIOSuite.scala:2603` adds a blank line before the closing brace of the
class, which the base does not have.
##########
sql/core/src/test/scala/org/apache/spark/sql/execution/datasources/parquet/ParquetIOSuite.scala:
##########
@@ -2569,6 +2581,26 @@ class ParquetIOSuite extends ParquetTest with
SharedSparkSession {
val e = readCorruptedRleSection("id >= 0 as b", writerVersion =
"PARQUET_2_0")(setMaxLength)
assertInvalidLengthReportsColumn(e, "b")
}
+
+ test("SPARK-59854: a ZSTD page that cannot be decompressed reports the
column") {
+ Seq(PageType.DATA_PAGE -> false, PageType.DICTIONARY_PAGE -> true).foreach
{
+ case (pageType, dictionary) =>
+ val options = Map(
+ ParquetOutputFormat.ENABLE_DICTIONARY -> dictionary.toString,
+ "compression" -> "zstd")
+ val e = readCorruptedFile("cast(id % 10 as int) as a", options) {
bytes =>
+ // The first page of the only column chunk starts right after the
magic number.
+ val in = new ByteArrayInputStream(bytes,
ParquetFileWriter.MAGIC.length, bytes.length)
Review Comment:
Nit: `bytes.length` is 4 bytes more than what is left after the offset. It
works because `ByteArrayInputStream` caps `count` at `buf.length`, which
`pageStart` at L2596 also relies on. Could we pass `bytes.length -
ParquetFileWriter.MAGIC.length`, like `MapOutputTracker.scala:1780`?
##########
sql/core/src/test/scala/org/apache/spark/sql/execution/datasources/parquet/ParquetVectorizedSuite.scala:
##########
@@ -585,6 +573,66 @@ class ParquetVectorizedSuite extends ParquetTest with
SharedSparkSession {
}
}
+ test("SPARK-59854: a decoding error in a batch reports the page and the
column") {
+ Seq(
+ // Dictionary ids (bit width 1) with an RLE run of only 2 of the 4
values.
+ ("Corrupted RLE data: reading past the end of the encoded values",
Some(new DictionaryPage(
+ BytesInput.from(Array[Byte](1, 0, 0, 0, 2, 0, 0, 0)), 2,
Encoding.PLAIN_DICTIONARY)),
+ Array[Byte](1, 2 << 1, 0), Encoding.RLE_DICTIONARY),
+ // 2 of the 4 PLAIN values.
+ ("Failed to read 16 bytes", None, Array[Byte](1, 0, 0, 0, 2, 0, 0, 0),
Encoding.PLAIN)
+ ).foreach { case (error, dictionaryPage, values, encoding) =>
+ val e = readCorruptedPage("int32", dictionaryPage, fourNonNullValues ++
values, 4, encoding)
+ assert(e.getMessage.startsWith("could not decode page Page
[bytes.size="), e.getMessage)
+ assert(e.getMessage.contains(s"in col [a] optional int32 a: $error"),
e.getMessage)
+ assert(e.getCause.isInstanceOf[ParquetDecodingException], e)
+ assert(e.getCause.getMessage == error, e)
+ }
+ }
+
+ test("SPARK-59854: a dictionary that cannot be decoded reports the column") {
+ Seq(
+ // A PLAIN BINARY dictionary page that declares 3 entries but holds only
1, which cuts off
+ // the length of the second entry.
+ ("binary", new DictionaryPage(BytesInput.from(Array[Byte](1, 0, 0, 0,
'x')), 3,
+ Encoding.PLAIN_DICTIONARY), classOf[IndexOutOfBoundsException]),
+ // A dictionary page with a negative dictionary size.
+ ("int32", new DictionaryPage(BytesInput.from(Array[Byte](1, 0, 0, 0)),
-1,
+ Encoding.PLAIN_DICTIONARY), classOf[NegativeArraySizeException]),
+ // A dictionary page with an encoding that has no dictionary.
+ ("int32", new DictionaryPage(BytesInput.from(Array[Byte](1, 0, 0, 0)), 1,
+ Encoding.DELTA_BINARY_PACKED), classOf[UnsupportedOperationException])
+ ).foreach { case (columnType, dictionaryPage, errorClass) =>
+ val e = readCorruptedPage(columnType, Some(dictionaryPage),
oneValueWithDictionaryId0, 1,
+ Encoding.RLE_DICTIONARY)
+ assert(e.getMessage.startsWith(
+ s"could not decode the dictionary for [a] optional $columnType a:
${errorClass.getName}"),
+ e.getMessage)
+ assert(e.getCause.getClass == errorClass, e)
+ }
+ }
+
+ test("SPARK-59854: invalid DELTA_BINARY_PACKED data reports the column") {
+ // One non-null value. The header has a block of 128 values in 3 mini
blocks.
+ val e = readCorruptedPage("int32", None,
+ Array[Byte](2, 0, 0, 0, 2, 1, 0x80.toByte, 1, 3, 1, 0), 1,
Encoding.DELTA_BINARY_PACKED)
+ assert(e.getMessage.contains("in col [a] optional int32 a: Invalid
DELTA_BINARY_PACKED " +
+ "mini block count: 3 (block size in values: 128, the mini block size
must be a multiple " +
+ "of 8)"), e.getMessage)
+ assert(e.getCause.isInstanceOf[ParquetDecodingException], e)
+
+ // Two non-null values, so the second one unpacks the first mini block.
The header has a
+ // block of 128 values in 4 mini blocks, 2 values and the first value 0.
The block has the
+ // min delta 0 and the mini block bit widths 65, 0, 0 and 0.
+ val e2 = readCorruptedPage("int32", None,
Review Comment:
Nit: this case repeats the setup and three of the four assertions of "a
decoding error in a batch reports the page and the column" (L576-L591), without
its check of the cause's message (L589). With 4 values it fits there as a third
row, since the second value still unpacks the first mini block:
```scala
("Invalid DELTA_BINARY_PACKED mini block bit width: 65 (must be at most
64)", None,
Array[Byte](0x80.toByte, 1, 4, 4, 0, 0, 65, 0, 0, 0),
Encoding.DELTA_BINARY_PACKED)
```
Could we move it there, so that this test keeps only the header case?
##########
sql/core/src/main/java/org/apache/spark/sql/execution/datasources/parquet/VectorizedColumnReader.java:
##########
@@ -272,79 +288,96 @@ void readBatch(
}
readState.resetForNewPage(pageValueCount, pageFirstRowIndex);
}
- PrimitiveType.PrimitiveTypeName typeName =
- descriptor.getPrimitiveType().getPrimitiveTypeName();
- if (isCurrentPageDictionaryEncoded) {
- // Save starting offset in case we need to decode dictionary IDs.
- int startOffset = readState.valueOffset;
- // Save starting row index so we can check if we need to eagerly
decode dict ids later
- long startRowId = readState.rowId;
-
- // Read and decode dictionary ids.
- if (readState.maxRepetitionLevel == 0) {
- defColumn.readIntegers(readState, dictionaryIds, column,
definitionLevels,
- (VectorizedValuesReader) dataColumn);
- } else {
- repColumn.readIntegersRepeated(readState, repetitionLevels,
defColumn, definitionLevels,
- dictionaryIds, column, (VectorizedValuesReader) dataColumn);
- }
+ // A corrupted page makes the readers below throw a
ParquetDecodingException without the
+ // column. Only that is wrapped, so that other exceptions, e.g.
SparkUpgradeException,
+ // keep their types.
+ try {
+ PrimitiveType.PrimitiveTypeName typeName =
+ descriptor.getPrimitiveType().getPrimitiveTypeName();
+ if (isCurrentPageDictionaryEncoded) {
+ // Save starting offset in case we need to decode dictionary IDs.
+ int startOffset = readState.valueOffset;
+ // Save starting row index so we can check if we need to eagerly
decode dict ids later
+ long startRowId = readState.rowId;
+
+ // Read and decode dictionary ids.
+ if (readState.maxRepetitionLevel == 0) {
+ defColumn.readIntegers(readState, dictionaryIds, column,
definitionLevels,
+ (VectorizedValuesReader) dataColumn);
+ } else {
+ repColumn.readIntegersRepeated(readState, repetitionLevels,
defColumn, definitionLevels,
+ dictionaryIds, column, (VectorizedValuesReader) dataColumn);
+ }
- // TIMESTAMP_MILLIS encoded as INT64 can't be lazily decoded as we
need to post process
- // the values to add microseconds precision.
- if (column.hasDictionary() || (startRowId == pageFirstRowIndex &&
- isLazyDecodingSupported(typeName, column.dataType()))) {
- // Column vector supports lazy decoding of dictionary values so just
set the dictionary.
- // We can't do this if startRowId is not the first row index in the
page AND the column
- // doesn't have a dictionary (i.e. some non-dictionary encoded
values have already been
- // added).
- PrimitiveType primitiveType = descriptor.getPrimitiveType();
-
- // We need to make sure that we initialize the right type for the
dictionary otherwise
- // WritableColumnVector will throw an exception when trying to
decode to an Int when the
- // dictionary is in fact initialized as Long
- LogicalTypeAnnotation typeAnnotation =
primitiveType.getLogicalTypeAnnotation();
- boolean castLongToInt =
- typeAnnotation instanceof DecimalLogicalTypeAnnotation annotation
&&
- annotation.getPrecision() <= Decimal.MAX_INT_DIGITS() &&
- primitiveType.getPrimitiveTypeName() == INT64;
-
- // We require a long value, but we need to use dictionary to decode
the original
- // signed int first
- boolean isUnsignedInt32 =
updaterFactory.isUnsignedIntTypeMatched(32);
-
- // We require a decimal value, but we need to use dictionary to
decode the original
- // signed long first
- boolean isUnsignedInt64 =
updaterFactory.isUnsignedIntTypeMatched(64);
-
- boolean needTransform = castLongToInt || isUnsignedInt32 ||
isUnsignedInt64;
- column.setDictionary(new ParquetDictionary(dictionary,
needTransform));
- } else {
- updater.decodeDictionaryIds(readState.valueOffset - startOffset,
startOffset, column,
- dictionaryIds, dictionary);
- }
- } else {
- if (column.hasDictionary() && readState.valueOffset != 0) {
- // This batch already has dictionary encoded values but this new
page is not. The batch
- // does not support a mix of dictionary and not so we will decode
the dictionary.
- updater.decodeDictionaryIds(readState.valueOffset, 0, column,
dictionaryIds, dictionary);
- }
- column.setDictionary(null);
- VectorizedValuesReader valuesReader = (VectorizedValuesReader)
dataColumn;
- if (readState.maxRepetitionLevel == 0) {
- defColumn.readBatch(readState, column, definitionLevels,
valuesReader, updater);
+ // TIMESTAMP_MILLIS encoded as INT64 can't be lazily decoded as we
need to post process
+ // the values to add microseconds precision.
+ if (column.hasDictionary() || (startRowId == pageFirstRowIndex &&
+ isLazyDecodingSupported(typeName, column.dataType()))) {
+ // Column vector supports lazy decoding of dictionary values so
just set the dictionary.
+ // We can't do this if startRowId is not the first row index in
the page AND the column
+ // doesn't have a dictionary (i.e. some non-dictionary encoded
values have already been
+ // added).
+ PrimitiveType primitiveType = descriptor.getPrimitiveType();
+
+ // We need to make sure that we initialize the right type for the
dictionary otherwise
+ // WritableColumnVector will throw an exception when trying to
decode to an Int when the
+ // dictionary is in fact initialized as Long
+ LogicalTypeAnnotation typeAnnotation =
primitiveType.getLogicalTypeAnnotation();
+ boolean castLongToInt =
+ typeAnnotation instanceof DecimalLogicalTypeAnnotation
annotation &&
+ annotation.getPrecision() <= Decimal.MAX_INT_DIGITS() &&
+ primitiveType.getPrimitiveTypeName() == INT64;
+
+ // We require a long value, but we need to use dictionary to
decode the original
+ // signed int first
+ boolean isUnsignedInt32 =
updaterFactory.isUnsignedIntTypeMatched(32);
+
+ // We require a decimal value, but we need to use dictionary to
decode the original
+ // signed long first
+ boolean isUnsignedInt64 =
updaterFactory.isUnsignedIntTypeMatched(64);
+
+ boolean needTransform = castLongToInt || isUnsignedInt32 ||
isUnsignedInt64;
+ column.setDictionary(new ParquetDictionary(dictionary,
needTransform));
+ } else {
+ updater.decodeDictionaryIds(readState.valueOffset - startOffset,
startOffset, column,
+ dictionaryIds, dictionary);
+ }
} else {
- repColumn.readBatchRepeated(readState, repetitionLevels, defColumn,
definitionLevels,
- column, valuesReader, updater);
+ if (column.hasDictionary() && readState.valueOffset != 0) {
+ // This batch already has dictionary encoded values but this new
page is not. The batch
+ // does not support a mix of dictionary and not so we will decode
the dictionary.
+ updater.decodeDictionaryIds(readState.valueOffset, 0, column,
dictionaryIds,
+ dictionary);
+ }
+ column.setDictionary(null);
+ VectorizedValuesReader valuesReader = (VectorizedValuesReader)
dataColumn;
+ if (readState.maxRepetitionLevel == 0) {
+ defColumn.readBatch(readState, column, definitionLevels,
valuesReader, updater);
+ } else {
+ repColumn.readBatchRepeated(readState, repetitionLevels,
defColumn, definitionLevels,
+ column, valuesReader, updater);
+ }
}
+ } catch (ParquetDecodingException e) {
+ throw new ParquetDecodingException("could not decode page " +
currentPage + " in col " +
+ descriptor + ": " + e.getMessage(), e);
}
}
}
private int readPage() {
- DataPage page = pageReader.readPage();
+ DataPage page;
+ try {
+ // This decompresses the page eagerly for some codecs, e.g. ZSTD.
+ page = pageReader.readPage();
+ } catch (ParquetDecodingException e) {
+ throw new ParquetDecodingException(
+ "could not read the next page in col " + descriptor + ": " +
e.getMessage(), e);
+ }
if (page == null) {
return -1;
}
+ this.currentPage = page.toString();
Review Comment:
Minor: the second bullet of item 5 of my second review was incomplete. A
negative `uncompressed_page_size` also breaks the data pages of the codecs that
decompress lazily, SNAPPY (the default), GZIP and LZ4, not only ZSTD and
LZ4_RAW. For those, `readPage()` returns normally, because parquet-java checks
only `compressed_page_size` and wraps the stream as `BytesInput.from(is, -1)`,
so this line records `Page [bytes.size=-1, ...]`. Then `bytes.toInputStream()`
at L491, or `page.getData().toInputStream()` at L518 for a V2 page, allocates
`new byte[-1]` in `StreamBytesInput.toByteArray`. The
`NegativeArraySizeException` passes the catches at L497-L502 and L520-L525, so
the error names neither the page nor the column. A dictionary page with the
same header is wrapped by the new arm at L167 for every compressed codec.
Could we reword the "not covered" item, e.g. "A negative uncompressed size
in the header of a compressed data page fails with a
`NegativeArraySizeException`, in `readPage()` for ZSTD and LZ4_RAW and in
`readPageV1` or `readPageV2` for the other codecs"?
##########
sql/core/src/main/java/org/apache/spark/sql/execution/datasources/parquet/VectorizedDeltaBinaryPackedReader.java:
##########
@@ -472,9 +476,15 @@ private void readBlockHeader() {
* see
org.apache.parquet.column.values.delta.DeltaBinaryPackingValuesReader#unpackMiniBlock
*/
private void unpackMiniBlock() throws IOException {
+ int bitWidth = bitWidths[currentMiniBlock];
+ // The bit widths of the unused mini blocks of the last block can be
anything, so a width is
+ // checked only when its mini block is unpacked.
+ if (bitWidth > 64) {
Review Comment:
Minor: the user-facing section of the description says that the original
`IllegalArgumentException`, `ArrayIndexOutOfBoundsException` and the others are
kept as the cause. That holds only for the three dictionary types
(`VectorizedColumnReader.java:175`). Here and at L91 and L108, a new
`ParquetDecodingException` is thrown before an `ArrayIndexOutOfBoundsException`
or an `IllegalArgumentException` can occur, so the cause is this reader's own
exception, as the new tests check (`ParquetVectorizedSuite.scala:622` and
`:633`, versus `:611` for the dictionaries). Also, only the value count and
mini block size checks threw an `IllegalArgumentException` before; the other
header checks already threw a `ParquetDecodingException`.
Since the description becomes the commit message, could we say so, e.g. "The
dictionary exceptions are kept as the cause, and the DELTA_BINARY_PACKED checks
now throw a `ParquetDecodingException` instead of an `IllegalArgumentException`
or `ArrayIndexOutOfBoundsException`"?
--
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]