dongjoon-hyun commented on code in PR #59117:
URL: https://github.com/apache/spark/pull/59117#discussion_r4147262164


##########
sql/core/src/main/java/org/apache/spark/sql/execution/datasources/parquet/VectorizedRleValuesReader.java:
##########
@@ -976,14 +984,26 @@ private int readIntLittleEndianPaddedOnBitWidth() throws 
IOException {
   }
 
   /**
-   * Reads the next group. Returns false if no more group available.
+   * Reads the next non-empty group, so that `currentCount` is positive on 
return.
+   * <p>
+   * Callers never read more values than the page declares, so running out of 
encoded data here
+   * means the page is corrupted. With a bit width of 0, `initFromPage` 
already provides all the
+   * values of the page, so reaching here also means reading past the end.
    */
-  private boolean readNextGroup() {
-    if (in.available() <= 0) {
-      currentCount = 0;
-      return false;
-    }
+  private void readNextGroup() {
+    do {
+      if (bitWidth == 0 || in.available() <= 0) {

Review Comment:
   I checked this one, and it doesn't hang. In parquet-java 1.18.1 (and 
1.17.0), `SingleBufferInputStream.read()` and `MultiBufferInputStream.read()` 
throw `EOFException` at the end of the stream instead of returning -1, and 
these are the only stream types that `BytesInput.toInputStream()` and 
`sliceStream()` produce. So `readUnsignedVarInt()` stops there and 
`readGroup()` wraps it.
   
   On this PR's head, a dictionary id page `[4, 0x80]` and a V1 level section 
with a declared length of 1 whose only byte is `0x80` both fail with 
`ParquetDecodingException: Corrupted RLE data: failed to read from input 
stream` (cause `EOFException`), on single-buffer and multi-buffer streams. The 
reader before this PR fails the same way, with the old message.
   
   There is no test for this path though, so I asked for one in the comment on 
L1048. Its 30s timeout would catch a hang if the stream behavior ever changed.



##########
sql/core/src/main/java/org/apache/spark/sql/execution/datasources/parquet/VectorizedRleValuesReader.java:
##########
@@ -1014,10 +1045,8 @@ private boolean readNextGroup() {
         }
       }
     } catch (IOException e) {
-      throw new ParquetDecodingException("Failed to read from input stream", 
e);
+      throw new ParquetDecodingException("Corrupted RLE data: failed to read 
from input stream", e);

Review Comment:
   No test reaches this path. Every new negative case ends in one of the 
explicit checks, and the old truncated-last-group input is now caught by the 
byte-count check above, so restoring the old message here still passes all 26 
tests in `VectorizedRleValuesReaderSuite`.
   
   Could you add a case where the data ends inside a run header or an RLE 
value? For example, through `streams` + `interceptCorrupted`:
   - `[4, 0x80]`: the run header's continuation byte is missing
   - `[4, 0x14]`: an RLE run of 10 without its value byte
   
   Both give "Corrupted RLE data: failed to read from input stream" (cause 
`EOFException`) on the single-buffer and the 1-byte-buffer streams, and the 30s 
timeout in `interceptCorrupted` would also cover the concern in the L995 thread.



##########
sql/core/src/test/scala/org/apache/spark/sql/execution/datasources/parquet/VectorizedRleValuesReaderSuite.scala:
##########
@@ -38,6 +45,17 @@ class VectorizedRleValuesReaderSuite extends SparkFunSuite {
 
   import VectorizedRleValuesReaderSuite._
 
+  /** Runs `f` in another thread, so that a reader that never returns fails 
the test. */
+  private def interceptCorrupted(f: => Any): Unit = {
+    // scalastyle:off awaitresult
+    // Await.result, unlike ThreadUtils.awaitResult, rethrows the exception 
unwrapped.
+    val e = intercept[ParquetDecodingException] {
+      Await.result(Future(f)(ExecutionContext.global), 30.seconds)
+    }
+    // scalastyle:on awaitresult
+    assert(e.getMessage.contains("Corrupted RLE data"), e.getMessage)

Review Comment:
   Since the generic `IOException` fallback now has the same "Corrupted RLE 
data" prefix, this assertion can't tell which check rejected the input, so the 
two new pre-allocation checks aren't pinned:
   - Without the `(long) numGroups * bitWidth > in.available()` check, the 
suite still passes with a heap of 5g or more: the `1L << 27` case really 
allocates `int[1 << 30]` and then fails in `in.slice` with the same prefix. 
With the default `-Xmx4g` it fails only after 30s with a `TimeoutException`, 
because the `OutOfMemoryError` never completes the Future.
   - Without the `numGroups > Integer.MAX_VALUE / 8` check, all tests pass: 
both overflow inputs are also rejected by the byte-count check.
   
   Could `interceptCorrupted` take the expected message fragment, e.g. "needs 
134217728 bytes, but only 3 bytes are left", "is too long", "reading past the 
end"? For the same reason, the bit-width-0 test could use a small header for 
`trailing` (e.g. `Array[Byte](3, 1, 2, 3)`), so that a regression of that guard 
fails with an assertion instead of a 4 GiB allocation.



##########
sql/core/src/main/java/org/apache/spark/sql/execution/datasources/parquet/VectorizedColumnReader.java:
##########
@@ -362,7 +363,7 @@ private void initDataReader(
 
     try {
       dataColumn.initFromPage(pageValueCount, in);
-    } catch (IOException e) {
+    } catch (IOException | ParquetDecodingException e) {

Review Comment:
   No test goes through this catch. Both end-to-end tests corrupt the 
definition levels of a V1 page: the truncated run throws at read time, and the 
invalid length is wrapped by the `readPageV1` catch. Changing this line back to 
`catch (IOException e)` doesn't fail anything.
   
   This catch is what gives V2 pages the column, because `readPageV2` still 
catches only `IOException`. For a boolean column written with 
`parquet.writer.version=PARQUET_2_0` (RLE values) whose 4-byte length is 
overwritten with `FF FF FF 7F`, I get
   
   ```
   could not read page Page V2 [...] in col [b] required boolean b
     <- could not read page in col [b] required boolean b
     <- Corrupted RLE data: invalid length 2147483647, 3 bytes are left in the 
page
   ```
   
   and without this line only `FAILED_READ_FILE <- ParquetDecodingException`. 
Could `readCorruptedDefinitionLevels` take the column expression and the writer 
version, and cover this case too? With `id >= 0 as b` the same byte pattern `03 
00 00 00 C8 01 01` matches the boolean data section. Note that the 
`ParquetDecodingException` is two levels below the first "could not read page" 
`IOException` there.



##########
sql/core/src/main/java/org/apache/spark/sql/execution/datasources/parquet/VectorizedRleValuesReader.java:
##########
@@ -100,12 +100,21 @@ public void initFromPage(int valueCount, 
ByteBufferInputStream in) throws IOExce
       // Initialize for repetition and definition levels
       if (readLength) {
         int length = readIntLittleEndian();
+        if (length < 0 || length > in.available()) {
+          throw new ParquetDecodingException("Corrupted RLE data: invalid 
length " + length +
+            ", " + in.available() + " bytes are left in the page");
+        }
         this.in = in.sliceStream(length);
       }
     } else {
       // Initialize for values
       if (in.available() > 0) {
         init(in.read());

Review Comment:
   Nit (pre-existing): a corrupted bit-width byte (33..255) still fails with 
`IllegalArgumentException: bitWidth must be >= 0 and <= 32` from `init()`. That 
isn't caught by the widened catches, so the error has neither the "Corrupted 
RLE data" prefix nor the page/column, unlike the level length just above. Since 
`in.read()` returns 0..255, something like
   
   ```java
   int bitWidth = in.read();
   if (bitWidth > 32) {
     throw new ParquetDecodingException("Corrupted RLE data: invalid bit width 
" + bitWidth);
   }
   init(bitWidth);
   ```
   
   would let the existing catch in `initDataReader` add the column. Fine to 
leave it for SPARK-59854 if you prefer.



##########
sql/core/src/main/java/org/apache/spark/sql/execution/datasources/parquet/VectorizedRleValuesReader.java:
##########
@@ -976,14 +984,26 @@ private int readIntLittleEndianPaddedOnBitWidth() throws 
IOException {
   }
 
   /**
-   * Reads the next group. Returns false if no more group available.
+   * Reads the next non-empty group, so that `currentCount` is positive on 
return.
+   * <p>
+   * Callers never read more values than the page declares, so running out of 
encoded data here
+   * means the page is corrupted. With a bit width of 0, `initFromPage` 
already provides all the
+   * values of the page, so reaching here also means reading past the end.
    */
-  private boolean readNextGroup() {
-    if (in.available() <= 0) {
-      currentCount = 0;
-      return false;
-    }
+  private void readNextGroup() {
+    do {
+      if (bitWidth == 0 || in.available() <= 0) {
+        throw new ParquetDecodingException(
+          "Corrupted RLE data: reading past the end of the encoded values");
+      }
+      readGroup();
+    } while (this.currentCount == 0);

Review Comment:
   A question on skipping runs of length 0 instead of rejecting them. 
parquet-java's encoder never writes an empty run and Arrow C++ treats a zero 
count as invalid, so in practice an empty run means a corrupted header. With 
skipping, the same bytes decode differently depending on the reader: for a 
dictionary id section `[2][00 03][C8 01 01]` (an empty RLE run of id 3, then 
100 x id 1), the vectorized reader returns `dictionary[1]` for all 100 rows, 
and `spark.sql.parquet.enableVectorizedReader=false` returns `dictionary[3]`, 
both without an error.
   
   Would it be simpler to throw "Corrupted RLE data" for a run of length 0? 
Then the loop isn't needed. The batch paths already skipped such runs before 
this PR, so keeping it is defensible too; I just want to make sure it is a 
deliberate choice.



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