Repository navigation
Conversation
This is a follow-up of SPARK-59812. It makes `VectorizedRleValuesReader`, which decodes definition/repetition levels, dictionary ids and RLE booleans, fail with a `ParquetDecodingException` when the encoded data ends early or a header is invalid.
- `readNextGroup()` throws when there is no encoded data left, instead of returning false. Callers never read more values than the page declares, so running out of data always means a corrupt page. All the `if (currentCount == 0 && !readNextGroup()) break;` loop guards become `if (currentCount == 0) readNextGroup();`. With a bit width of 0, `initFromPage` already provides every value of the page, so reaching `readNextGroup()` also means reading past the end.
- `readNextGroup()` skips runs of length 0, so `currentCount` is always positive when it returns.
- For a bit-packed run, check that `numGroups * 8` does not overflow and that `numGroups * bitWidth` bytes are left before allocating the value buffer.
- `initFromPage` rejects a level length that is negative or larger than the bytes left in the page.
- A dictionary-encoded page with no data at all (not even the bit width byte) no longer decodes every value as id 0. Reading from it fails, like parquet-java's `DictionaryValuesReader` ("Attempt to read from empty page"). An all-null page, which reads no ids, still works.
The added checks run once per run or page, not per value.
SPARK-34863 made `readNextGroup()` return false at the end of the stream. Before that, it called `in.read()`, which throws an `EOFException` that was wrapped in a `ParquetDecodingException`. Since then, on a corrupt page:
- The batch read and skip loops stop early, so dictionary ids are only partially read or skipped, and the query returns wrong results without an error.
- For definition and repetition levels, `readBatch` and `readBatchRepeated` return without making progress, and `VectorizedColumnReader.readBatch` loops forever because `rowsToReadInBatch` and `valuesToReadInPage` never change.
- `readBooleans` ignores the return value, and loops forever at the end of the stream.
- `readInteger` ignores the return value and decrements `currentCount` below 0, returning the last run's value again. It also returns the value of a run of length 0 instead of skipping it.
- A bit-packed header with a large group count overflows `numGroups * 8` and `numGroups * bitWidth` (the latter was added by SPARK-56895). `in.slice` with a negative length moves a `SingleBufferInputStream` backwards, and a large count allocates up to an `int[2^31 - 8]` buffer before noticing that the bytes are missing.
- In Parquet 1.18.1, `SingleBufferInputStream.sliceBuffers` only rejects `length > remaining`, so a negative level length moves the stream backwards and the next section of the page is decoded from the wrong offset.
The non-vectorized reader already fails on these pages: parquet-java's `RunLengthBitPackingHybridDecoder` throws "Reading past RLE/BitPacking stream.".
Only for corrupt Parquet files: reading a page whose levels, dictionary ids or RLE booleans end early, or have an invalid length or bit-packed header, now fails with a `ParquetDecodingException` instead of returning wrong values, spinning forever or running out of memory. Valid files are not affected.
New unit tests in `VectorizedRleValuesReaderSuite`. Most run both on a single buffer and on the same data split across multiple buffers. Tests that could loop forever before this change run with a timeout.
- Truncated dictionary ids on `readIntegers`, `skipIntegers` and `readInteger`; truncated booleans on `readBooleans`; truncated definition levels on `readBatch` (with and without definition level output, and with row ranges that skip past the truncation); truncated repetition levels on `readBatchRepeated`; reading past a page with bit width 0.
- Negative and oversized (up to `Int.MaxValue`) level lengths.
- Bit-packed runs that are truncated, that overflow `currentCount` to 0 or a negative value, or that would allocate a huge buffer.
- A dictionary-encoded page with no data, and runs of length 0.
- Positive cases: reads and skips that end exactly at the end of the encoded values for mixed RLE and bit-packed dictionary ids and booleans, with a level length that covers exactly the rest of the page.
A new end-to-end test in `ParquetIOSuite` shortens the definition level run of a written file and checks that the vectorized reader fails instead of hanging.
All the new negative tests fail without this change. Existing suites also pass: `ParquetEncodingSuite`, `ParquetVectorizedSuite`, `ParquetColumnIndexSuite`, `ParquetIOSuite`, `ParquetV1QuerySuite` and `ParquetV2QuerySuite`.
Generated-by: Claude Code (Claude Opus 5.5)
HyukjinKwon
left a comment
There was a problem hiding this comment.
Thanks @viirya, this looks correct to me. I found no valid-file regressions and have no blocking concerns.
What I checked:
- Callers only read what the page declares. Definition and repetition levels are bounded by
valuesToReadInPage, including the!state.lastListCompletedcase inreadBatchRepeated, which still requiresleftInPage > 0. Dictionary ids are read only for non-null definition-level runs, so an all-null page reads zero ids and the new empty-page handling doesn't affect it.VectorizedColumnReaderalso creates a new dictionary-id reader for each page, so the early return ininitFromPagecan't reuse state from an earlier page. - Bit width 0.
initFromPageprovides every value of the page, so throwing inreadNextGroupforbitWidth == 0only fires when reading past the page. - Corrupt headers. The overflow check (
numGroups > Integer.MAX_VALUE / 8plus alongbyte count checked againstavailable()) runs before any allocation, and(int) totalBytesis safe after it. The zero-length-run loop consumes at least one byte per iteration, so it is bounded by the page size. - Truncation inside a header or RLE value. Both
SingleBufferInputStream.read()andMultiBufferInputStream.read()in parquet-common 1.18.1 throwEOFExceptionat end of stream, and the existingcatch (IOException e)wraps it. These cases also fail with aParquetDecodingException, just with the message "Failed to read from input stream" instead of "Corrupted RLE data". That seems fine to me. - Tests. The hand-built fixtures decode as intended. For example, the
Int.MaxValuegroup count encodes asff ff ff ff 0f, which reads back as int-1, soheader >>> 1isInt.MaxValueand the run is bit-packed. The new negative tests fail against the pre-PRreadNextGroup, which returned false at the end of the stream.
One style nit is inline.
| produced += toRead | ||
| } | ||
| } | ||
| test("SPARK-59832: truncated dictionary ids fail instead of being partially read") { |
There was a problem hiding this comment.
Nit: missing blank line before this test.
dongjoon-hyun
left a comment
There was a problem hiding this comment.
A few nits:
VectorizedRleValuesReaderL1038: as noted in the earlier review, truncation inside a run header or an RLE value still fails correctly, just with "Failed to read from input stream". A "Corrupted RLE data: ..." prefix there would make the messages consistent for all truncation shapes.VectorizedRleValuesReaderL1013-1016: when onlynumGroups > Integer.MAX_VALUE / 8is true, the message can read "needs 268435456 bytes, but only 268435460 bytes are left". Maybe split the two conditions.VectorizedRleValuesReaderL969:case 0 -> 0inreadIntLittleEndianPaddedOnBitWidthis now unreachable because of the newbitWidth == 0guard.VectorizedRleValuesReaderSuiteL296: a freshOnHeapColumnVectoris zero-filled, so this assertion passes even if nothing is written. Filling it with a sentinel first would make it meaningful.VectorizedRleValuesReaderSuiteL283: casting to the package-privateParquetReadStatebypasses theParquetTestAccessbridge that the suite doc says it uses.
| // Validate before allocating the buffer, so a corrupted header can neither overflow | ||
| // `currentCount` nor make us allocate more than the remaining bytes can fill. | ||
| long totalBytes = (long) numGroups * bitWidth; | ||
| if (numGroups > Integer.MAX_VALUE / 8 || totalBytes > in.available()) { |
There was a problem hiding this comment.
This check also rejects a final bit-packed run whose trailing zero padding is missing, even when every value the page needs is present. parquet-java's RunLengthBitPackingHybridDecoder tolerates this (bytesToRead = Math.min(bytesToRead, in.available())), and Arrow C++ accepts it since apache/arrow#47992. Such files exist in the wild: Polars before pola-rs/polars#13883 (see pola-rs/polars#13818) and Impala 1.1.1.
It's not a regression, because the previous in.slice(totalBytes) threw an EOFException too. But the description says "The non-vectorized reader already fails on these pages", which isn't true here, and the "Truncated last group" test now pins the difference: [4][0x05][0x11 x 5] read as 10 values decodes fine with spark.sql.parquet.enableVectorizedReader=false. Could we fix the description, and maybe file a follow-up to accept a short last group the way Arrow does (only values fully covered by the remaining bytes, only within the last group)?
There was a problem hiding this comment.
Good catch, the description was wrong there. I updated it: this PR keeps rejecting a short last group as before (in.slice already threw), and notes that parquet-java and Arrow C++ accept it. The test case now has a comment saying the same. I filed SPARK-59853 to accept it the way Arrow does.
| run(withDefLevels = false, rowIndexes = longIterator((80 to 90).toArray)) | ||
| } | ||
|
|
||
| test("SPARK-59832: truncated repetition levels fail in readBatchRepeated") { |
There was a problem hiding this comment.
None of the new tests reach the new throw sites in readValues (L649) or skipValues(int, ParquetReadState, ...) (L743). This test truncates only the repetition levels, and in the flat row-range case readBatchInternal never calls skipValues with more than currentCount, so it throws at L220 instead. On master, truncated definition levels in the repeated path don't hang; they silently produce wrong nested values because the trailing def levels stay 0. Could you add a readBatchRepeated case that truncates only the def-level stream, with and without row indexes?
There was a problem hiding this comment.
Added "truncated definition levels fail in readBatchRepeated", with and without row indexes. They reach the throws in readValues and in skipValues(int, ParquetReadState, ...) respectively. On master this case returns without an error, so I also added it to the description.
| // Initialize for repetition and definition levels | ||
| if (readLength) { | ||
| int length = readIntLittleEndian(); | ||
| if (length < 0 || length > in.available()) { |
There was a problem hiding this comment.
Before this change, a positive length larger than the rest of the page made sliceStream throw an EOFException, and readPageV1 (or initDataReader for RLE booleans) wrapped it as "could not read page ... in col ...". The new ParquetDecodingException is unchecked, so it skips those wrappers and the error no longer says which column is corrupt. It matches SPARK-59812, but for wide tables it may be worth also catching ParquetDecodingException there (or in VectorizedColumnReader.readBatch) and adding the column descriptor.
There was a problem hiding this comment.
Agreed. readPageV1 and initDataReader now also catch ParquetDecodingException and wrap it with the page and column like an IOException, and a new end-to-end test checks that an oversized definition level length reports the column. Decoding errors thrown later from readBatch never had the column, including the ones from SPARK-59812, so I filed SPARK-59854 for that.
| private object VectorizedRleValuesReaderSuite { | ||
|
|
||
| /** The page as a single buffer and split into 3-byte buffers. */ | ||
| private def streams(bytes: Array[Byte]): Seq[() => ByteBufferInputStream] = Seq( |
There was a problem hiding this comment.
When a page is 3 bytes or less, bytes.grouped(3) yields one chunk, and ByteBufferInputStream.wrap(List) then returns a SingleBufferInputStream. So the "multiple buffers" variant is single-buffer for the truncated dictionary id page ([4, 20, 3]). In the boolean test, the RLE bytes also end up in a single buffer after sliceStream(2). As a result, no test hits the end-of-data check on a MultiBufferInputStream. Could we reuse VectorizedPlainValuesReaderSuite.toStream(bytes, split = true), which asserts that the page spans more than one buffer, or split into 1-byte buffers?
There was a problem hiding this comment.
Right, thanks. streams now splits into 1-byte buffers, so any part of the page longer than 1 byte, including a slice of it, stays on a MultiBufferInputStream. I didn't reuse VectorizedPlainValuesReaderSuite.toStream because its 3-byte split has the same problem after a slice.
| case RLE -> { | ||
| this.currentCount = header >>> 1; | ||
| this.currentValue = readIntLittleEndianPaddedOnBitWidth(); | ||
| do { |
There was a problem hiding this comment.
For backports: wrapping the whole body in do { ... } while (currentCount == 0) re-indents about 20 unchanged lines. branch-4.2/4.1/4.0 still slice per group (they don't have SPARK-56895), so the cherry-pick conflicts in the PACKED loop. If the current body becomes a readGroup() helper called from do { readGroup(); } while (currentCount == 0);, and the new checks are added without touching the existing int totalBytes = numGroups * bitWidth; in.slice(totalBytes) lines (use the long multiplication only inside the check), it applies cleanly to every maintenance branch in my simulation. Either way, the backports need to keep the numGroups > Integer.MAX_VALUE / 8 || totalBytes > in.available() pre-check before currentBuffer is allocated.
There was a problem hiding this comment.
Done. The existing body is now readGroup() and readNextGroup() loops over it. The int totalBytes = numGroups * bitWidth; in.slice(totalBytes) lines are unchanged, and the long multiplication is only in the new check, before currentBuffer is allocated.
- Move the existing group decoding unchanged into `readGroup()`, called in a loop by `readNextGroup()`, and split the two bit-packed run checks. - Prefix read errors inside a run header or an RLE value with "Corrupted RLE data". - Remove the unreachable `case 0` in `readIntLittleEndianPaddedOnBitWidth`. - Wrap a `ParquetDecodingException` thrown while initializing a page with the page and column, like an `IOException`. - Test truncated definition levels in `readBatchRepeated`, an oversized definition level length end to end, and multiple buffers with 1-byte splits; add a `readBatchRepeated` bridge to `ParquetTestAccess`.
|
@dongjoon-hyun Thanks, addressed the nits:
|
| } | ||
| private void readNextGroup() { | ||
| do { | ||
| if (bitWidth == 0 || in.available() <= 0) { |
There was a problem hiding this comment.
The "fail on truncated input" goal has one remaining gap: a run header whose varint is truncated at end-of-page still hangs rather than throwing.
This guard rejects an empty stream, but a stream ending in a lone continuation byte (e.g. a trailing 0x80) passes it, and the readGroup() below then loops forever in readUnsignedVarInt(): at EOF in.read() returns -1, and (-1 & 0x80) != 0 is always true, so the do/while never terminates. It's reachable on a corrupt V1 level section (e.g. a declared length of 1 whose only byte is 0x80).
This is pre-existing — the old readNextGroup had the same guard and called the same readUnsignedVarInt — so it's a "fold in here or follow-up?" like the short-last-group case. The minimal fix is to treat EOF as corruption in the varint loop:
b = in.read();
if (b < 0) {
throw new ParquetDecodingException(
"Corrupted RLE data: reading past the end of the encoded values");
}There was a problem hiding this 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.
There was a problem hiding this comment.
Thanks for checking, you're right. In parquet-common 1.18.1 both SingleBufferInputStream.read() and MultiBufferInputStream.read() throw EOFException at the end of the stream instead of returning -1, so a truncated header fails with "Corrupted RLE data: failed to read from input stream" rather than hanging. I withdraw this one; a test like the [4, 0x80] case you suggested on L1048 would pin it.
There was a problem hiding this comment.
Thanks for confirming. I added "data ending inside a run header or an RLE value fails" with [4, 0x80] and [4, 0x14], on single and 1-byte buffers; it runs with a timeout, so a hang would fail it too.
dongjoon-hyun
left a comment
There was a problem hiding this comment.
Thanks for addressing the first round, @viirya. The main change looks correct to me, and I found no regression for valid files. The remaining items are test gaps and a few optional nits; I left the concrete ones inline.
What I checked on 4121cb9:
VectorizedRleValuesReaderSuitepasses (26/26). On the merge base, 10 of the 11 new tests fail and only the positive one passes.- The readers before and after this PR return identical results for about 460K randomly generated valid streams (dictionary ids and booleans, flat
readBatch,readBatchRepeated, with and without row ranges, V1 and V2 layouts, single, multiple and direct buffers). - About 380K corrupted streams: no hang, and no exception other than
ParquetDecodingException("Corrupted RLE data ..."), anEOFExceptionfor a truncated length prefix, and theIllegalArgumentExceptionmentioned inline. - A micro-benchmark shows no measurable change for typical run lengths (504-value bit-packed runs, long RLE runs) and about 1-4% for synthetic streams that start a new run every 8 values.
- The two main files merge cleanly into branch-4.3/4.2/4.1/4.0. The tests conflict on branch-4.2, and
VectorizedRleValuesReaderSuiteandParquetTestAccessdon't exist on branch-4.1/4.0.
Follow-up candidates (pre-existing, not blocking):
- An RLE run value wider than the bit width is still accepted. With definition levels
C8 01 03(one byte off fromC8 01 01), the vectorized reader returns 100 NULL rows without an error, whilespark.sql.parquet.enableVectorizedReader=falsefails. A per-run check inreadGroup()such asbitWidth < 32 && (currentValue >>> bitWidth) != 0would catch it. VectorizedColumnReader.readBatchstill has a silent exit:if (pageValueCount < 0) break;also leaves the loop whenrowsToReadInBatch > 0. With a column-index-filtered read and a page header whosenum_valuesis understated (100 -> 70), the batch comes back with shifted and stale values and no error. Throwing there when rows are still owed would close it.- The dictionary-page wrapper in the
VectorizedColumnReaderconstructor (L131-136) still catches onlyIOException, so a truncated dictionary page (ParquetDecodingException: could not read int) is reported without the column. It needs the same one-line change as L366/L435, so it could also go into this PR.
Nits (optional):
- Data-reader init errors are now wrapped twice (
initDataReader, thenreadPageV1/readPageV2), which gives a 5-element cause chain. Spark Connect forwards at most 5 (ErrorUtils.MAX_ERROR_CHAIN_LENGTH), and inside a scalar subquery the chain is 7, so the root cause is cut. Keepingcatch (IOException e)at L366 and widening the catch inreadPageV2(L455) instead gives one "could not read page ... in col ..." layer and a 4-element chain for both V1 and V2. interceptCorruptedruns the read onExecutionContext.global. AnOutOfMemoryErrornever completes the Future, so it shows up as a 30sTimeoutException, and the suite now logsPOSSIBLE THREAD LEAK ... scala-execution-context-global-N. A dedicatedThreadUtils.newDaemonSingleThreadExecutorwithsubmit(...).get(30, SECONDS)avoids both, and thescalastyle:off awaitresult.VectorizedRleValuesReaderSuiteL117 and L155 still say the buffer growth and the slice position are "in readNextGroup"; that code is inreadGroup()now. The class Scaladoc could also mention the corrupt-input tests.intLEandvarintduplicateBytesUtils.intToBytesandBytesInput.fromUnsignedVarLong(v).toByteArrayfrom parquet-common.- PR description: "Most run both on a single buffer and ... 1-byte buffers" - 5 of the 11 new unit tests go through
streams. - If this is backported, the empty dictionary-id page change (it used to decode as id 0) may deserve a line in the release notes. As far as I could check, no writer produces such pages.
| } | ||
| } 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); |
There was a problem hiding this 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.
There was a problem hiding this comment.
Added "data ending inside a run header or an RLE value fails" with both inputs, on single and 1-byte buffers.
| Await.result(Future(f)(ExecutionContext.global), 30.seconds) | ||
| } | ||
| // scalastyle:on awaitresult | ||
| assert(e.getMessage.contains("Corrupted RLE data"), e.getMessage) |
There was a problem hiding this 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: the1L << 27case really allocatesint[1 << 30]and then fails inin.slicewith the same prefix. With the default-Xmx4git fails only after 30s with aTimeoutException, because theOutOfMemoryErrornever completes the Future. - Without the
numGroups > Integer.MAX_VALUE / 8check, 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.
There was a problem hiding this comment.
Done. interceptCorrupted now takes the expected message, so each case pins the check that rejects it, e.g. "is too long" and "needs 134217728 bytes, but only 3 bytes are left". The bit width 0 test uses [3, 1, 2, 3] now.
| try { | ||
| dataColumn.initFromPage(pageValueCount, in); | ||
| } catch (IOException e) { | ||
| } catch (IOException | ParquetDecodingException e) { |
There was a problem hiding this 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.
There was a problem hiding this comment.
Thanks. Following your double-wrapping nit, I moved the catch from initDataReader to readPageV2, so V1 and V2 both get one "could not read page ... in col ..." directly on top of the ParquetDecodingException. readCorruptedRleSection now takes the column and the writer version, a new test covers the RLE boolean length in a V2 page, and both length tests check that the page error appears once.
| } else { | ||
| // Initialize for values | ||
| if (in.available() > 0) { | ||
| init(in.read()); |
There was a problem hiding this 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
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.
There was a problem hiding this comment.
Done. It now throws "Corrupted RLE data: invalid bit width N", which readPageV1/readPageV2 wrap with the page and column.
| "Corrupted RLE data: reading past the end of the encoded values"); | ||
| } | ||
| readGroup(); | ||
| } while (this.currentCount == 0); |
There was a problem hiding this 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.
There was a problem hiding this comment.
It's deliberate. The spec doesn't allow runs of length 0 (both run lengths must be in [1, 2^31 - 1]), and Arrow C++ rejects them, but the batch paths already skipped them before this PR, so rejecting them could make a file that reads today fail. Skipping them in readInteger too makes all paths agree. The non-vectorized result in your example (dictionary[3] for all rows) comes from RunLengthBitPackingHybridDecoder.readInt decrementing currentCount to -1 and never reading the next header, so it isn't a useful reference either. I added a line on this to the description.
| throw new ParquetDecodingException( | ||
| "Corrupted RLE data: bit-packed run of " + numGroups + " groups is too long"); | ||
| } | ||
| if ((long) numGroups * bitWidth > in.available()) { |
There was a problem hiding this comment.
This bounds the run by the bytes that are left, but not by the number of values in the page, so a bit-packed run whose bytes are all present can still allocate far more than the page needs. For example, a dictionary id section [01][PACKED header for 2^26 groups][2^26 zero bytes] in a page with valueCount = 1 passes both checks, allocates int[2^29] (2 GiB) and unpacks about 537M ids to return one. 64 MiB of zeros compresses to about 65 KB with GZIP, so a small file can do this in every task.
It isn't a regression, since parquet-java's RunLengthBitPackingHybridDecoder also allocates new int[numGroups * 8] without a bound. But initFromPage already receives valueCount, so keeping it and adding something like
if (numGroups > (valueCount + 7L) / 8) {
throw new ParquetDecodingException("Corrupted RLE data: bit-packed run of " + numGroups +
" groups is longer than the " + valueCount + " values in the page");
}would cap the buffer at the page's value count with one more comparison per run. A run of a valid stream never has more than ceil(valueCount / 8) groups.
There was a problem hiding this comment.
I tried this, but it also rejects a non-compliant stream that decodes correctly today: for example, 9 values written as a bit-packed run of 3 groups currently return the right 9 values, because the extra values are never read. I'd like to keep this PR to inputs that can't be read correctly today, so I left this cap for a follow-up, together with the run value and header length checks.
| @@ -100,12 +100,21 @@ public void initFromPage(int valueCount, ByteBufferInputStream in) throws IOExce | |||
| // Initialize for repetition and definition levels | |||
| if (readLength) { | |||
| int length = readIntLittleEndian(); | |||
There was a problem hiding this comment.
Nit (pre-existing): if the page ends inside this 4-byte length, e.g. a V1 page of an optional column whose data is only 2 bytes, readIntLittleEndian() fails with a bare EOFException whose message is null. readPageV1 still adds the page and column, but unlike the other truncation shapes there is no "Corrupted RLE data" text, and no test covers it. The length of RLE boolean values goes through the same path. Checking the remaining bytes first would make it consistent:
if (in.available() < 4) {
throw new ParquetDecodingException("Corrupted RLE data: the 4-byte length is cut off, only " +
in.available() + " bytes are left in the page");
}| */ | ||
| private void readGroup() { | ||
| try { | ||
| int header = readUnsignedVarInt(); |
There was a problem hiding this comment.
Two more pre-existing gaps around this decoding, as possible follow-ups next to the run-value check from my review:
- The varint header has no length limit. A run length must fit in 31 bits, so a valid header is at most 5 bytes with a 5th byte of at most
0x0F, butreadUnsignedVarInt()keeps going and Java masks the shift:[80 80 80 80 80 01]decodes to 8, i.e. an RLE run of 4, and the high bits of a 5th byte are dropped. Rejecting a 6th byte, or bits above0x0Fin the 5th, would turn this into "Corrupted RLE data". - Dictionary ids are not checked against the dictionary size. For a lazily decoded column, e.g.
required int32with a 3-entry dictionary and the id section[02 0A 03](an RLE run of five id 3),readBatchreturns normally, and theArrayIndexOutOfBoundsExceptionis thrown later fromParquetDictionary.decodeToIntwhen a downstream operator reads the vector. That is outsideFileScanRDD.hasNext, so the error has no file path andspark.sql.files.ignoreCorruptFilesdoesn't apply. An RLE run could be checked once per run, but bit-packed ids would need a check per value, so this probably belongs in a follow-up.
There was a problem hiding this comment.
Thanks. The header length limit has the same issue as the run count cap: a 6-byte header with zero high bytes (e.g. 94 80 80 80 80 00) decodes correctly today because Java masks the shift amount, so I left it for the follow-up. The dictionary id bound needs a per-value check for bit-packed ids, so I'd keep it separate too.
- Reject a 4-byte level length that is cut off, and a dictionary id bit width above 32, with "Corrupted RLE data". - Wrap a `ParquetDecodingException` thrown while initializing a V2 page in `readPageV2` instead of `initDataReader`, so the page and column are added once, and also wrap it when decoding the dictionary page. - Check the expected message in `interceptCorrupted`, run it on a dedicated thread pool, and test data ending inside a run header or an RLE value, a cut-off length, an invalid bit width and an invalid RLE boolean length in a V2 page. - Use `BytesUtils.intToBytes` and `BytesInput.fromUnsignedVarLong`, and update stale comments.
|
@HyukjinKwon Thanks for the re-review. The three test items are addressed in 1a2868d: @dongjoon-hyun Thanks for the thorough check. Addressed in 1a2868d:
For item 1 (an RLE run value wider than the bit width), I tried the per-run check, but a boolean RLE run with value byte |
dongjoon-hyun
left a comment
There was a problem hiding this comment.
Thanks for the update, @viirya. I re-checked 1a2868d. The RLE decoding changes still look correct to me, and I found no regression for valid files. But the widened ParquetDecodingException catch in VectorizedColumnReader now breaks a master test, so this needs a rebase and a fix before it can be merged. Details are in the inline comments, in the same order as the list below.
What I checked:
- Every caller still reads at most the values a page declares (levels through
valuesToReadInPage, dictionary ids and booleans once per non-null level), so the new throws can't fire on valid files. The test fixtures decode as their comments say, and each new check fails a test when removed; the only untested change is the dictionary catch (item 7). - Every stream that reaches
initFromPageis aSingleBufferInputStreamor aMultiBufferInputStream, and in parquet-java 1.18.1 both report the exact number of remaining bytes inavailable(), so the new length checks can't reject a valid page. - In the CI run on 1a2868d, which merges master, the only test failure is item 1. The documentation job failed on an unrelated SparkR CDN download.
Must fix
VectorizedColumnReaderL435: the widened catch also wraps theParquetDecodingExceptionthat SPARK-59831 (merged to master after this PR's base) throws from the BYTE_STREAM_SPLIT reader, andreadPage()rethrows it as aRuntimeException. So master'sParquetVectorizedSuitetest for SPARK-59831 fails. The same test is on branch-4.x and branch-4.3.
Error reporting
VectorizedColumnReaderL455: a type-preservingParquetDecodingExceptionwrap that includese.getMessage()would fix item 1, avoid three different shapes for the same kind of corruption, and fit SPARK-59854.VectorizedColumnReaderL436: the wrappers drop the cause's message and add two layers, so inside a scalar or IN subquery, Spark Connect's 5-error chain no longer reaches theParquetDecodingExceptionfrom the other readers.VectorizedColumnReaderL131 (pre-existing): decompression errors fromreadDictionaryPage()andreadPage()are outside these catches, so a corrupted ZSTD or LZ4_RAW page still reports no column, while SNAPPY and GZIP do.
Description and limits
VectorizedRleValuesReaderL1031 (pre-existing): a bit-packed run whose bytes are all present is still not bounded by the page's value count, so "instead of ... running out of memory" is too broad. branch-3.5 already rejects such runs, and capping the decode instead of rejecting would keep the "9 values in 3 groups" stream readable.VectorizedRleValuesReaderL1003: withignoreCorruptFiles=true, the pages that used to read correctly by chance now lose the rest of the file, not only their own rows as the description says.
Tests
VectorizedColumnReaderL134: no test covers the widened dictionary catch.VectorizedRleValuesReaderSuiteL74: a timed-out read keeps spinning, so a regression would leave busy threads in the shared sql/core test JVM.ParquetIOSuiteL2546: a cause with a null message turns the assertion into a bareNullPointerException.ParquetIOSuiteL2521: the lastAwait.resultonExecutionContext.globalunderscalastyle:off awaitresult;failAfterwith aThreadSignaleris a drop-in.VectorizedRleValuesReaderSuiteL65: the doc leaves out the"Corrupted RLE data: "prefix, which three tests also write by hand.
Nits
VectorizedRleValuesReaderSuiteL473: the test counts in the description.VectorizedRleValuesReaderSuiteL232: the same read-and-intercept block appears three times.ParquetIOSuiteL2536:BytesUtils.intToBytesforsetMaxLength.VectorizedRleValuesReaderSuiteL58:awaitTerminationaftershutdownNow().
| initDataReader(pageValueCount, page.getValueEncoding(), in); | ||
| return pageValueCount; | ||
| } catch (IOException e) { | ||
| } catch (IOException | ParquetDecodingException e) { |
There was a problem hiding this comment.
This catch now also wraps the init-time ParquetDecodingException of the other value readers, including the one that SPARK-59831 (f868de6, merged to master after this PR's base) added to VectorizedByteStreamSplitValuesReader.initFromPage. readPage()'s visitor then rethrows the IOException as a RuntimeException, so master's ParquetVectorizedSuite test "SPARK-59831: BYTE_STREAM_SPLIT page of a required column with a wrong value count" fails:
Expected exception org.apache.parquet.io.ParquetDecodingException to be thrown, but java.lang.RuntimeException was thrown
This is the only test failure in the CI run on 1a2868d, which merges master. The same test is on branch-4.x and branch-4.3, so the backports would fail the same way; branch-4.2 and older don't have the vectorized BYTE_STREAM_SPLIT reader.
Could you rebase, and either keep the exception type here (see the comment on L455) or update that test in this PR? The test also checks the message (3 encoded values do not match the page value count 4), so a wrapper would need to include e.getMessage().
There was a problem hiding this comment.
Fixed in bf06bd8 with the type-preserving wrap from your L455 comment, and merged master in 517133c. The SPARK-59831 test passes now.
| initDataReader(pageValueCount, page.getDataEncoding(), page.getData().toInputStream()); | ||
| return pageValueCount; | ||
| } catch (IOException e) { | ||
| } catch (IOException | ParquetDecodingException e) { |
There was a problem hiding this comment.
About where the page and column are added: turning the unchecked ParquetDecodingException into a checked IOException at three places (L134, L435 and here) leaves three shapes for the same kind of corruption:
- Errors thrown while reading, such as the shortened definition level run in the new
ParquetIOSuitetest, come fromreadBatchand reachFAILED_READ_FILEas a bareParquetDecodingExceptionwithout the page or column. - Errors thrown while initializing a page become
RuntimeException <- IOException("could not read page ... in col ...") <- ParquetDecodingException. - Errors in the dictionary page become
IOException("could not decode the dictionary for ...") <- ParquetDecodingException.
Keeping the type would avoid this, for example:
} catch (IOException e) {
throw new IOException("could not read page " + page + " in col " + descriptor, e);
} catch (ParquetDecodingException e) {
throw new ParquetDecodingException(
"could not read page " + page + " in col " + descriptor + ": " + e.getMessage(), e);
}That fixes the SPARK-59831 test from the L435 comment, keeps this PR's ParquetIOSuite assertions, and drops the extra RuntimeException layer. SPARK-59854 could then add the column to the errors thrown from readBatch with the same kind of catch, instead of catching RuntimeException, which would also wrap the SchemaColumnConvertNotSupportedException and SparkUpgradeException that FileDataSourceV2.attachFilePath dispatches on.
There was a problem hiding this comment.
Done, at all three places including the dictionary page. Errors thrown while initializing a page or decoding the dictionary now come as a ParquetDecodingException with the page or dictionary, the column and the original message, without the RuntimeException layer. The errors thrown from readBatch are left for SPARK-59854, along the lines you describe.
| return pageValueCount; | ||
| } catch (IOException e) { | ||
| } catch (IOException | ParquetDecodingException e) { | ||
| throw new IOException("could not read page " + page + " in col " + descriptor, e); |
There was a problem hiding this comment.
Neither wrapper message includes the cause's message, and with the widened catch the init-time ParquetDecodingExceptions of the other readers (for example the length decoding of DELTA_LENGTH_BYTE_ARRAY and DELTA_BYTE_ARRAY pages, and the checks from SPARK-59812 and SPARK-59831) end up two levels deeper. Outside a subquery that's fine (4 or 5 throwables), but ThreadUtils.awaitResult in a scalar or IN subquery adds two more. For a DELTA_LENGTH_BYTE_ARRAY page with truncated length mini-blocks, the chain is now
SparkException("Exception thrown in awaitResult") <- ExecutionException <- FAILED_READ_FILE
<- RuntimeException <- IOException("could not read page ... in col ...")
<- ParquetDecodingException("Error reading mini block.") <- EOFException
and Spark Connect forwards only the first 5 (ErrorUtils.MAX_ERROR_CHAIN_LENGTH), so the client sees the page and column but not the reason. Before this PR it was SparkException <- ExecutionException <- FAILED_READ_FILE <- ParquetDecodingException <- EOFException, all delivered. Appending ": " + e.getMessage() here, or the type-preserving wrap from the L455 comment, would keep the reason visible.
There was a problem hiding this comment.
The wrapper now includes the original message, so the reason is in the same exception that adds the column, and the chain is one layer shorter than before, since the RuntimeException from readPage() is gone too.
|
|
||
| DictionaryPage dictionaryPage = pageReader.readDictionaryPage(); | ||
| if (dictionaryPage != null) { | ||
| try { |
There was a problem hiding this comment.
(Pre-existing) Related to reporting the column: pageReader.readDictionaryPage() (L129, just above this try) and pageReader.readPage() (L312) are outside every catch that adds it. In parquet-java 1.18.1, CodecFactory.HeapBytesDecompressor decompresses ZSTD and LZ4_RAW pages eagerly inside those calls, but SNAPPY and GZIP pages lazily, inside bytes.toInputStream() or initDictionary. So the same corrupted page reports could not read page ... in col [a] ... with SNAPPY or GZIP, but a bare ParquetDecodingException("could not decompress page") (or "Could not decompress dictionary page") without the column with ZSTD, and a bare MalformedInputException with LZ4_RAW. Fine to leave this for SPARK-59854; note that L129 needs its own try, because its result decides the if.
There was a problem hiding this comment.
Agreed, I'll leave this for SPARK-59854, including the separate try that L129 needs.
| throw new ParquetDecodingException( | ||
| "Corrupted RLE data: bit-packed run of " + numGroups + " groups is too long"); | ||
| } | ||
| if ((long) numGroups * bitWidth > in.available()) { |
There was a problem hiding this comment.
Coming back to the bound from the earlier thread, with two new data points. This is pre-existing, not a regression.
- The description says these pages now fail "instead of ... running out of memory", but a bit-packed run whose bytes are all present is still not bounded by the page's value count. A dictionary id section of a 1-value page,
[01][81 80 80 40][2^26 zero bytes](2^26 groups; the zeros gzip to about 65 KB), passes both checks, allocatesint[2^29](2 GiB) and unpacks about 537M ids to return one. With a 1g heap that is anOutOfMemoryError, whichignoreCorruptFilesdoesn't cover. - branch-3.5 has 4e61adc ("Bounds-check RLE/bit-packed group counts in the vectorized Parquet reader", not released yet). It keeps
pageValueCountand rejects runs longer than the page (count > pageValueCount + 7for bit-packed runs), including the "9 values in 3 groups" stream. So the same file already behaves differently on branch-3.5 and master, and a 3.5 backport of this PR would conflict in this block.
Capping instead of rejecting would address the concern about streams that read correctly today: keep valueCount, decode only min(run length, values left) values (ceil(n / 8) groups for a bit-packed run), and throw in readNextGroup when no values are left. The "9 values in 3 groups" stream still returns its 9 values, the buffer is bounded by the page, and the bitWidth == 0 special case in readNextGroup becomes the same check. If you'd rather leave this for SPARK-59853, could the description be limited to headers that overflow or whose bytes are missing?
There was a problem hiding this comment.
Thanks for the data points. I'd rather leave the bound for a follow-up: capping changes what we decode, and it should be decided together with the branch-3.5 behavior from 4e61adc, which rejects such runs. I narrowed the description to headers that overflow or whose bytes are missing, and added a note that a run whose bytes are all present isn't bounded yet.
| } | ||
|
|
||
| /** | ||
| * Runs `f` in another thread, so that a reader that never returns fails the test, and checks |
There was a problem hiding this comment.
The doc says the message must contain expected, but L79 checks "Corrupted RLE data: " + expected. The three initFromPage tests (L347, L396 and L407) also repeat the same intercept and prefixed contains by hand, so the prefix is written in four places. Since initFromPage has no loop, a thread-less assertCorrupted(expected)(f) that interceptCorrupted uses around future.get would keep the check in one place, and the doc could mention the prefix.
There was a problem hiding this comment.
Added assertCorrupted(expected)(f), which interceptCorrupted uses around future.get. The three initFromPage tests use it, so the prefix is only there and in its doc.
| * The page as a single buffer and split into 1-byte buffers. The split keeps any part of the | ||
| * page longer than 1 byte, including a slice of it, on a `MultiBufferInputStream`. | ||
| */ | ||
| private def streams(bytes: Array[Byte]): Seq[() => ByteBufferInputStream] = { |
There was a problem hiding this comment.
Nit on the description: 8 of the 14 SPARK-59832 tests go through streams, not 7 ("data ending inside a run header or an RLE value" also uses it). Also, the three initFromPage negative tests (cut-off length, invalid length and invalid bit width) use a plain intercept on the test thread rather than "a separate thread with a timeout".
There was a problem hiding this comment.
Fixed in the description: 8 of 14, and only the tests that read values run on a separate thread.
|
|
||
| test("SPARK-59832: truncated dictionary ids fail instead of being partially read") { | ||
| // A dictionary id page with 10 ids, read as if it had 20. | ||
| streams(dictIdPage(Array.fill(10)(3), bitWidth = 4)).foreach { in => |
There was a problem hiding this comment.
Nit: this block (a new reader, initFromPage(n, in()), a new vector, then interceptCorrupted(expected)(reader.readIntegers(n, c, 0)) for each of streams(page)) is repeated at L357-362 and L384-389 with only the page, the count and the message changed, and this test builds the same dictIdPage(Array.fill(10)(3), bitWidth = 4) three times. A class-level assertReadIntegersFails(page, n, expected) would cover the three blocks and turn check into one line.
There was a problem hiding this comment.
Added assertReadIntegersFails(page, n, expected). The three blocks and check use it, and the test builds the page once.
| Iterator.iterate(e)(_.getCause).takeWhile(_ != null).toSeq | ||
|
|
||
| /** Sets the 4-byte length at `offset` to Int.MaxValue, far more than the rest of the page. */ | ||
| private def setMaxLength(bytes: Array[Byte], offset: Int): Unit = |
There was a problem hiding this comment.
Nit: BytesUtils.intToBytes(Int.MaxValue).copyToArray(bytes, offset) writes the same FF FF FF 7F, like VectorizedRleValuesReaderSuite does after the intLE comment in the last round.
|
|
||
| override def afterAll(): Unit = { | ||
| try { | ||
| executor.shutdownNow() |
There was a problem hiding this comment.
Nit: SparkTestSuite.afterAll runs doThreadPostAudit() right after this, and shutdownNow() only interrupts the idle rle-reader-suite-N worker, so the worker can still be alive when the audit lists the threads. The CI run on 1a2868d didn't print the warning for this suite, so it's timing-dependent, but executor.awaitTermination(10, TimeUnit.SECONDS) after shutdownNow() would make it much less likely.
There was a problem hiding this comment.
Added awaitTermination(10, SECONDS) after shutdownNow().
…runcated Co-authored-by: Isaac <[email protected]>
dongjoon-hyun
left a comment
There was a problem hiding this comment.
Thank you for the update, @viirya. I reviewed the latest commit, 517133c, and left 4 inline comments, continuing the numbering from items 1-15. Here is a summary, ordered by priority.
The latest commit only merges master, so items 1-15 of my previous review are still open. Item 1 in particular still applies: with master merged in, ParquetVectorizedSuite "SPARK-59831: BYTE_STREAM_SPLIT page of a required column with a wrong value count" still expects a bare ParquetDecodingException, which the widened catch in VectorizedColumnReader turns into a RuntimeException. I also checked LateMaterializationParquetRecordReader from SPARK-59620, which came in with the merge. Its phase 1 catches the new decoding errors and hands the row group to phase 2, as its comment describes, so I found no new issue there.
Error reporting
VectorizedRleValuesReaderL122: an empty dictionary id section fails with the generic "reading past the end of the encoded values", which does not say that the bit width byte is missing.
Tests
VectorizedRleValuesReaderSuiteL435: runs of length 0 are tested only on a single buffer, not throughstreams.
Cleanup
ParquetIOSuiteL2511:indexOfSliceinstead of a slice per byte offset.ParquetIOSuiteL2532:ExceptionUtils.getThrowableListinstead of a hand-written cause walk.
The most important item is still item 1, which blocks the merge.
| } | ||
| init(width); | ||
| } else { | ||
| // No encoded values (e.g. an all-null page). Do not treat this as a bit width of 0, |
There was a problem hiding this comment.
Minor: when the data section is empty, this returns with bitWidth still at its default 0, so the first id read fails in readNextGroup with Corrupted RLE data: reading past the end of the encoded values. That reads like a truncated run, while the actual problem is that the page has no bit width byte at all. The description compares this case to parquet-java's DictionaryValuesReader, which says "Attempt to read from empty page". Could the message for this case say that the page has no encoded data, for example by remembering it here and checking it in readNextGroup?
There was a problem hiding this comment.
Done in 7cdea62. initFromPage now remembers that the section is empty, and the first read fails with "Corrupted RLE data: the page has no data, not even the bit width".
| interceptCorrupted(PastEnd)(reader.readIntegers(5, c, 0)) | ||
| } | ||
|
|
||
| test("SPARK-59832: runs of length 0 are skipped") { |
There was a problem hiding this comment.
Nit: unlike the other hand-built page tests, this one wraps only a single ByteBuffer, so the bit-packed run of 0 groups is never sliced from a MultiBufferInputStream. In parquet-java 1.18.1, MultiBufferInputStream.slice(0) returns a shared EMPTY buffer, while SingleBufferInputStream.slice(0) returns a duplicate at the current position. Could this test also go through streams(page)? The truncated level tests for readBatch (L272) and readBatchRepeated (L303) are single-buffer too, but their levels are RLE runs only, so no slice happens there.
There was a problem hiding this comment.
Done, both reads in that test now go through streams(page).
| val file = dir.listFiles().filter(_.getName.endsWith(".parquet")).head | ||
| val bytes = Files.readAllBytes(file.toPath) | ||
| val section = Array[Byte](3, 0, 0, 0, 0xC8.toByte, 0x01, 1) | ||
| val offsets = bytes.indices.filter { i => |
There was a problem hiding this comment.
Nit: this allocates a 7-byte array for every byte offset of the file. bytes.indexOfSlice(section) finds the offset without the copies, and bytes.indexOfSlice(section, offset + 1) < 0 keeps the uniqueness check. Could this use it?
There was a problem hiding this comment.
Done, with indexOfSlice and the uniqueness check you suggested.
| error | ||
| } | ||
|
|
||
| private def causes(e: Throwable): Seq[Throwable] = |
There was a problem hiding this comment.
Nit: commons-lang3's ExceptionUtils.getThrowableList(e).asScala.toSeq returns the same list, and it also stops on a cause chain that loops back, where Iterator.iterate(e)(_.getCause) never ends and the test would hang instead of failing. Could this use it?
|
@viirya, FYI, the CI run on the latest commit, 517133c, still fails because of item 1 of my earlier review. The widened |
- Wrap a `ParquetDecodingException` thrown while initializing a page or decoding the dictionary page in a `ParquetDecodingException` that adds the page or dictionary, the column and the original message, instead of an `IOException`. This keeps the type that callers and the SPARK-59831 test expect, and drops the extra `RuntimeException` layer from `readPage()`. - Test a corrupted dictionary page in `ParquetVectorizedSuite`. - In `VectorizedRleValuesReaderSuite`, add `assertCorrupted` and `assertReadIntegersFails`, stop running reads after the first timeout, and wait for the thread pool to terminate. - In `ParquetIOSuite`, use `failAfter` with a `ThreadSignaler` instead of `Await.result`, tolerate causes without a message, and use `BytesUtils.intToBytes`. Co-authored-by: Isaac <[email protected]>
- An empty dictionary id section now fails with "the page has no data, not even the bit width" instead of the generic "reading past the end of the encoded values". - Run the test for runs of length 0 on multiple buffers too. - In `ParquetIOSuite`, find the RLE section with `indexOfSlice` and list causes with `ExceptionUtils.getThrowableList`. Co-authored-by: Isaac <[email protected]>
|
@dongjoon-hyun Thanks for the re-checks. Item 1 is fixed in bf06bd8, which landed after the 517133c merge you reviewed: the wrap keeps the |
dongjoon-hyun
left a comment
There was a problem hiding this comment.
Thank you for addressing all the earlier comments, @viirya. I re-checked the latest commit, 7cdea62: the type-preserving wrap fixes the SPARK-59831 failure, CI is green, and the PR merges cleanly with the current master. I found no blocker this time, only minor items, which I left as inline comments. In summary:
-
VectorizedColumnReader.javaL157: A cut-off BINARY dictionary still loses the column(Pre-existing) parquet-java throws an
IndexOutOfBoundsExceptionfor it, which passes through both catches. The new test covers only the INT32 case. -
VectorizedRleValuesReader.javaL1010: The empty page message points at the whole pageWhen this fires, the page has its levels and only the dictionary id section is empty.
-
VectorizedColumnReader.javaL462: Two shapes for errors frominitDataReaderThe inner
IOExceptionwrap ininitDataReader(L390-392) now duplicates the wrap here and inreadPageV2. -
VectorizedRleValuesReaderSuite.scalaL443: PR description9 of the 14 SPARK-59832 tests now go through
streams, not 8. -
ParquetVectorizedSuite.scalaL547: The test depends on a parquet-java message"could not read int" comes from
IntegerPlainValuesReader, so a parquet upgrade that rewords it would break the test. -
VectorizedRleValuesReaderSuite.scalaL60:lazy val executorafterAllalways forces it, so a plainvalwould be equivalent and simpler. -
VectorizedRleValuesReader.javaL1008: TheemptyPagecheck can move above the loopThe flag can't change inside the loop, and the first iteration throws when it is set.
| this.isCurrentPageDictionaryEncoded = true; | ||
| } catch (IOException e) { | ||
| throw new IOException("could not decode the dictionary for " + descriptor, e); | ||
| } catch (ParquetDecodingException e) { |
There was a problem hiding this comment.
(Pre-existing) This adds the column to a ParquetDecodingException, but not every corrupt dictionary page throws one. In parquet-java 1.18.1, PlainBinaryDictionary's constructor reads the length of each entry with BytesUtils.readIntLittleEndian(ByteBuffer, int), which uses the absolute ByteBuffer.get(int). So a BINARY (string) dictionary page that is cut short fails with an IndexOutOfBoundsException, which passes through both catches and reaches FAILED_READ_FILE without "could not decode the dictionary for ...".
The new ParquetVectorizedSuite test covers the INT32 case, where IntegerPlainValuesReader happens to throw a ParquetDecodingException ("could not read int"). Not a blocker for this PR. It could go with SPARK-59854, for example by also wrapping a RuntimeException from initDictionary in a ParquetDecodingException that adds the column.
There was a problem hiding this comment.
Thanks, confirmed: PlainBinaryDictionary reads the entry lengths with the absolute ByteBuffer.get(int), so a short BINARY dictionary fails with an IndexOutOfBoundsException. I'll leave it for SPARK-59854 as you suggest, together with the decompression errors.
| do { | ||
| if (emptyPage) { | ||
| throw new ParquetDecodingException( | ||
| "Corrupted RLE data: the page has no data, not even the bit width"); |
There was a problem hiding this comment.
Minor: when this fires, the page usually does have data. Its definition levels say that there are non-null values, and only the dictionary id section after them is empty. "the page has no data" then points at the wrong part when someone inspects the file. Could the message, and the field comment at L65 ("True if the page has no data at all"), say that the dictionary id section is empty instead? For example, "Corrupted RLE data: the dictionary id section is empty, not even the bit width".
There was a problem hiding this comment.
Agreed. In 3398c18 the message is "the dictionary id section is empty, not even the bit width", and the field is renamed to emptyIdSection with a matching comment.
| return pageValueCount; | ||
| } catch (IOException e) { | ||
| throw new IOException("could not read page " + page + " in col " + descriptor, e); | ||
| } catch (ParquetDecodingException e) { |
There was a problem hiding this comment.
Minor: now that readPageV1 and readPageV2 both add the page and column around the whole initDataReader call, for both exception types, the inner catch (IOException e) around dataColumn.initFromPage in initDataReader (L390-392) only adds a second layer. An IOException from a values reader's initFromPage comes as "could not read page Page [...] in col X" on top of "could not read page in col X", while a ParquetDecodingException from the same call gets only the outer one.
assertInvalidLengthReportsColumn in ParquetIOSuite checks for exactly one page error, which holds only on the ParquetDecodingException path. Dropping the inner catch would give both exception types the same shape.
There was a problem hiding this comment.
Done, the inner catch in initDataReader is gone, so an IOException from a values reader's initFromPage also gets a single "could not read page ... in col ..." from readPageV1/readPageV2.
| } | ||
| } | ||
|
|
||
| test("SPARK-59832: runs of length 0 are skipped") { |
There was a problem hiding this comment.
Nit on the description: now that this test also goes through streams(page), 9 of the 14 SPARK-59832 tests run on both buffer layouts, not 8.
There was a problem hiding this comment.
Fixed in the description, 9 of 14.
| TestParquetRowGroupReader(Seq(TestPageReadStore(memPageStore, Seq(0L)))), 1) | ||
| val e = intercept[ParquetDecodingException](recordReader.nextKeyValue()) | ||
| assert(e.getMessage.contains("could not decode the dictionary for [a]"), e.getMessage) | ||
| assert(e.getMessage.contains("could not read int"), e.getMessage) |
There was a problem hiding this comment.
Nit: "could not read int" is parquet-java's message (from IntegerPlainValuesReader), so this line can break on a parquet upgrade that rewords it, even though the column is still reported. Since the point is that the wrapper includes the original message, assert(e.getMessage.contains(e.getCause.getMessage), e) would check that without the hard-coded text.
There was a problem hiding this comment.
Done, it now checks that the message includes the cause's message.
| private lazy val executor = ThreadUtils.newDaemonCachedThreadPool("rle-reader-suite") | ||
| @volatile private var timedOut = false | ||
|
|
||
| override def afterAll(): Unit = { |
There was a problem hiding this comment.
Nit: executor is a lazy val, but afterAll always forces it, so a run that skips every thread-based test (e.g. -z PACKED) creates the pool just to shut it down. That's harmless, since a cached pool starts no thread before the first submit, so a plain val would behave the same and be simpler.
| } | ||
| private void readNextGroup() { | ||
| do { | ||
| if (emptyPage) { |
There was a problem hiding this comment.
Nit: emptyPage doesn't change inside the loop, and when it's set the first iteration throws, so this check could move above the do. The behavior is the same; it just makes clear that the flag only matters before the first group.
There was a problem hiding this comment.
Done, the check is above the loop now.
…umn once - An empty dictionary id section now fails with "the dictionary id section is empty, not even the bit width", checked once before reading groups. - Remove the inner `IOException` wrapper in `initDataReader`, since `readPageV1` and `readPageV2` already add the page and column, so both exception types get one layer. - Check that the dictionary page error includes the original message instead of parquet-java's wording, and use a plain `val` for the test thread pool. Co-authored-by: Isaac <[email protected]>
dongjoon-hyun
left a comment
There was a problem hiding this comment.
Thank you for the quick update, @viirya. 3398c18 addresses all of my last comments, and it merges cleanly with the current master. CI is still running. I found only two minor items in the new commit, both in VectorizedColumnReader.java, which I left as inline comments. In summary:
-
VectorizedColumnReader.javaL388: The new comment says "any error"readPageV1andreadPageV2only wrap anIOExceptionor aParquetDecodingException. ThecheckArgumentcalls inVectorizedDeltaBinaryPackedReader.initFromPagestill pass through without the column. -
VectorizedColumnReader.javaL458: The missing dictionary error still gets two page layersIts message at L371-373 still starts with "could not read page in col", and this catch adds a second "could not read page".
| } catch (IOException e) { | ||
| throw new IOException("could not read page in col " + descriptor, e); | ||
| } | ||
| // readPageV1 and readPageV2 add the page and the column to any error thrown here. |
There was a problem hiding this comment.
Minor: readPageV1 and readPageV2 only add the page and column to an IOException or a ParquetDecodingException, not to any error. A values reader's initFromPage can still throw other runtime exceptions here. For example, VectorizedDeltaBinaryPackedReader.initFromPage checks the header with JavaUtils.checkArgument(miniSize % 8 == 0, ...), so a corrupt DELTA_BINARY_PACKED header with a block size of 128 and 3 mini blocks fails with an IllegalArgumentException that reaches FAILED_READ_FILE without the page or column. The same goes for its checkArgument(valueCount >= 1, ...).
Could the comment name the two types? Turning those checks into ParquetDecodingExceptions, like the block size checks next to them, could go with SPARK-59854.
There was a problem hiding this comment.
Fixed in d18a880: the comment now says that readPageV1 and readPageV2 add the page and column to an IOException or a ParquetDecodingException. Turning the checkArgument checks in VectorizedDeltaBinaryPackedReader.initFromPage into ParquetDecodingExceptions is left for SPARK-59854.
| @@ -455,6 +456,9 @@ private int readPageV1(DataPageV1 page) throws IOException { | |||
| return pageValueCount; | |||
| } catch (IOException e) { | |||
| throw new IOException("could not read page " + page + " in col " + descriptor, e); | |||
There was a problem hiding this comment.
Minor: with the inner catch gone, one IOException from initDataReader still gets two "could not read page" layers: the missing dictionary error at L371-373, whose message still starts with "could not read page in col X". So a page with RLE_DICTIONARY encoding in a column chunk without a dictionary page reports "could not read page Page [...] in col X" on top of "could not read page in col X as the dictionary was missing for encoding RLE_DICTIONARY". Shortening the inner message to "the dictionary was missing for encoding ..." would add the page and column once here too, as 3398c18 does for errors from initFromPage.
There was a problem hiding this comment.
Done, the inner message is now "the dictionary was missing for encoding ...", so the page and column are added once.
dongjoon-hyun
left a comment
There was a problem hiding this comment.
+1, LGTM (Pending CIs).
- Shorten the missing dictionary error in `initDataReader` to "the dictionary was missing for encoding ...", since `readPageV1` and `readPageV2` add the page and column. - Say which exception types `readPageV1` and `readPageV2` add the page and column to. Co-authored-by: Isaac <[email protected]>
|
Merge Summary:
Posted by |
|
Thanks @dongjoon-hyun @HyukjinKwon ! |
… reader ### What changes were proposed in this pull request? Backport of #59117 to branch-4.0. This branch does not have `VectorizedRleValuesReaderSuite`, `ParquetTestAccess` or `VectorizedRleValuesReaderTestUtils`, so this PR adds a `VectorizedRleValuesReaderSuite` with the 14 tests from #59117 and the helpers they use. It creates and drives the package-private `ParquetReadState` directly instead of through the `ParquetTestAccess` bridge. It also does not have SPARK-56895, so `readGroup` still slices a bit-packed run one group at a time; the new checks run before that loop, as on master. Tests that came into the conflicts as context but are not on this branch are left out (the SPARK-57828 test in `ParquetIOSuite` and the SPARK-59831 test in `ParquetVectorizedSuite`), and `ParquetVectorizedSuite` now imports `BytesInput`, `Encoding` and `Statistics` for the new dictionary page test. ### Why are the changes needed? Same as #59117. On a corrupt page, the vectorized RLE reader, which decodes definition/repetition levels, dictionary ids and RLE booleans, silently returned wrong values or looped forever when the encoded data ended early, and a corrupt bit-packed header could overflow `currentCount` or allocate a huge buffer. The reader now fails with a `ParquetDecodingException`. ### Does this PR introduce _any_ user-facing change? Only for corrupt or non-compliant Parquet files, as described in #59117: such pages now fail with a `ParquetDecodingException` instead of returning wrong values or hanging, and with `spark.sql.files.ignoreCorruptFiles=true` the rest of such a file is skipped. A few of these pages could be read before and gave the right result by chance; #59117 lists them. ### How was this patch tested? The 18 tests added in #59117 pass on this branch, as do the existing tests in `ParquetEncodingSuite`, `ParquetVectorizedSuite`, `ParquetColumnIndexSuite`, `ParquetIOSuite`, `ParquetV1QuerySuite`, `ParquetV2QuerySuite`, `ParquetDeltaByteArrayEncodingSuite`, `ParquetDeltaLengthByteArrayEncodingSuite` and `VectorizedPlainValuesReaderSuite`. Without the fix, the 13 negative tests in `VectorizedRleValuesReaderSuite` fail, as on master. ``` build/sbt 'sql/testOnly *VectorizedRleValuesReaderSuite *ParquetEncodingSuite *ParquetVectorizedSuite *ParquetIOSuite' ``` ### Was this patch authored or co-authored using generative AI tooling? Generated-by: Claude Code (Claude Opus 5.5) Closes #59484 from viirya/SPARK-59832-branch-4.0. Authored-by: Liang-Chi Hsieh <[email protected]> Signed-off-by: Liang-Chi Hsieh <[email protected]>
… reader ### What changes were proposed in this pull request? Backport of #59117 to branch-4.1. This branch does not have `VectorizedRleValuesReaderSuite`, `ParquetTestAccess` or `VectorizedRleValuesReaderTestUtils`, so this PR adds a `VectorizedRleValuesReaderSuite` with the 14 tests from #59117 and the helpers they use. It creates and drives the package-private `ParquetReadState` directly instead of through the `ParquetTestAccess` bridge. It also does not have SPARK-56895, so `readGroup` still slices a bit-packed run one group at a time; the new checks run before that loop, as on master. Tests that came into the conflicts as context but are not on this branch are left out (the SPARK-57828 test in `ParquetIOSuite` and the SPARK-59831 test in `ParquetVectorizedSuite`), and `ParquetVectorizedSuite` now imports `BytesInput`, `Encoding` and `Statistics` for the new dictionary page test. ### Why are the changes needed? Same as #59117. On a corrupt page, the vectorized RLE reader, which decodes definition/repetition levels, dictionary ids and RLE booleans, silently returned wrong values or looped forever when the encoded data ended early, and a corrupt bit-packed header could overflow `currentCount` or allocate a huge buffer. The reader now fails with a `ParquetDecodingException`. ### Does this PR introduce _any_ user-facing change? Only for corrupt or non-compliant Parquet files, as described in #59117: such pages now fail with a `ParquetDecodingException` instead of returning wrong values or hanging, and with `spark.sql.files.ignoreCorruptFiles=true` the rest of such a file is skipped. A few of these pages could be read before and gave the right result by chance; #59117 lists them. ### How was this patch tested? The 18 tests added in #59117 pass on this branch, as do the existing tests in `ParquetEncodingSuite`, `ParquetVectorizedSuite`, `ParquetColumnIndexSuite`, `ParquetIOSuite`, `ParquetV1QuerySuite`, `ParquetV2QuerySuite`, `ParquetDeltaByteArrayEncodingSuite`, `ParquetDeltaLengthByteArrayEncodingSuite` and `VectorizedPlainValuesReaderSuite`. Without the fix, the 13 negative tests in `VectorizedRleValuesReaderSuite` fail, as on master. ``` build/sbt 'sql/testOnly *VectorizedRleValuesReaderSuite *ParquetEncodingSuite *ParquetVectorizedSuite *ParquetIOSuite' ``` ### Was this patch authored or co-authored using generative AI tooling? Generated-by: Claude Code (Claude Opus 5.5) Closes #59483 from viirya/SPARK-59832-branch-4.1. Authored-by: Liang-Chi Hsieh <[email protected]> Signed-off-by: Liang-Chi Hsieh <[email protected]>
… reader ### What changes were proposed in this pull request? Backport of #59117 to branch-4.x. The cherry-pick conflicted only on code that SPARK-59620 added to master, which is not on this branch: an import in `VectorizedColumnReader` and `VectorizedRleValuesReaderSuite`, and a doc comment next to the new `readBatchRepeated` bridge in `ParquetTestAccess`. They are left out; otherwise the change is the same as on master. This commit also applies cleanly to branch-4.3, so it can be cherry-picked there when merging. The same change was built and tested on branch-4.3. ### Why are the changes needed? Same as #59117. On a corrupt page, the vectorized RLE reader, which decodes definition/repetition levels, dictionary ids and RLE booleans, silently returned wrong values or looped forever when the encoded data ended early, and a corrupt bit-packed header could overflow `currentCount` or allocate a huge buffer. The reader now fails with a `ParquetDecodingException`. ### Does this PR introduce _any_ user-facing change? Only for corrupt or non-compliant Parquet files, as described in #59117: such pages now fail with a `ParquetDecodingException` instead of returning wrong values or hanging, and with `spark.sql.files.ignoreCorruptFiles=true` the rest of such a file is skipped. A few of these pages could be read before and gave the right result by chance; #59117 lists them. ### How was this patch tested? The 18 tests added in #59117 pass on this branch, as do the existing tests in `ParquetEncodingSuite`, `ParquetVectorizedSuite`, `ParquetColumnIndexSuite`, `ParquetIOSuite`, `ParquetV1QuerySuite`, `ParquetV2QuerySuite`, `ParquetDeltaByteArrayEncodingSuite`, `ParquetDeltaLengthByteArrayEncodingSuite`, `VectorizedPlainValuesReaderSuite` and `ParquetVectorUpdaterSuite`. ``` build/sbt 'sql/testOnly *VectorizedRleValuesReaderSuite *ParquetEncodingSuite *ParquetVectorizedSuite *ParquetIOSuite' ``` ### Was this patch authored or co-authored using generative AI tooling? Generated-by: Claude Code (Claude Opus 5.5) Closes #59481 from viirya/SPARK-59832-branch-4.x. Authored-by: Liang-Chi Hsieh <[email protected]> Signed-off-by: Dongjoon Hyun <[email protected]>
… reader ### What changes were proposed in this pull request? Backport of #59117 to branch-4.x. The cherry-pick conflicted only on code that SPARK-59620 added to master, which is not on this branch: an import in `VectorizedColumnReader` and `VectorizedRleValuesReaderSuite`, and a doc comment next to the new `readBatchRepeated` bridge in `ParquetTestAccess`. They are left out; otherwise the change is the same as on master. This commit also applies cleanly to branch-4.3, so it can be cherry-picked there when merging. The same change was built and tested on branch-4.3. ### Why are the changes needed? Same as #59117. On a corrupt page, the vectorized RLE reader, which decodes definition/repetition levels, dictionary ids and RLE booleans, silently returned wrong values or looped forever when the encoded data ended early, and a corrupt bit-packed header could overflow `currentCount` or allocate a huge buffer. The reader now fails with a `ParquetDecodingException`. ### Does this PR introduce _any_ user-facing change? Only for corrupt or non-compliant Parquet files, as described in #59117: such pages now fail with a `ParquetDecodingException` instead of returning wrong values or hanging, and with `spark.sql.files.ignoreCorruptFiles=true` the rest of such a file is skipped. A few of these pages could be read before and gave the right result by chance; #59117 lists them. ### How was this patch tested? The 18 tests added in #59117 pass on this branch, as do the existing tests in `ParquetEncodingSuite`, `ParquetVectorizedSuite`, `ParquetColumnIndexSuite`, `ParquetIOSuite`, `ParquetV1QuerySuite`, `ParquetV2QuerySuite`, `ParquetDeltaByteArrayEncodingSuite`, `ParquetDeltaLengthByteArrayEncodingSuite`, `VectorizedPlainValuesReaderSuite` and `ParquetVectorUpdaterSuite`. ``` build/sbt 'sql/testOnly *VectorizedRleValuesReaderSuite *ParquetEncodingSuite *ParquetVectorizedSuite *ParquetIOSuite' ``` ### Was this patch authored or co-authored using generative AI tooling? Generated-by: Claude Code (Claude Opus 5.5) Closes #59481 from viirya/SPARK-59832-branch-4.x. Authored-by: Liang-Chi Hsieh <[email protected]> Signed-off-by: Dongjoon Hyun <[email protected]> (cherry picked from commit 45f1cc8) Signed-off-by: Dongjoon Hyun <[email protected]>
… reader ### What changes were proposed in this pull request? Backport of #59117 to branch-4.2. This branch does not have SPARK-56895, so `readGroup` still slices a bit-packed run one group at a time. The new checks run before that loop, as on master. Tests that came into the conflicts as context but are not on this branch are left out: the SPARK-57828 test in `ParquetIOSuite`, the SPARK-59831 test in `ParquetVectorizedSuite`, and the multi-buffer test from SPARK-56895 in `VectorizedRleValuesReaderSuite`. `ParquetVectorizedSuite` now imports `BytesInput`, `Encoding` and `Statistics` for the new dictionary page test, and the master-only SPARK-59620 import and doc comment are left out as well. ### Why are the changes needed? Same as #59117. On a corrupt page, the vectorized RLE reader, which decodes definition/repetition levels, dictionary ids and RLE booleans, silently returned wrong values or looped forever when the encoded data ended early, and a corrupt bit-packed header could overflow `currentCount` or allocate a huge buffer. The reader now fails with a `ParquetDecodingException`. ### Does this PR introduce _any_ user-facing change? Only for corrupt or non-compliant Parquet files, as described in #59117: such pages now fail with a `ParquetDecodingException` instead of returning wrong values or hanging, and with `spark.sql.files.ignoreCorruptFiles=true` the rest of such a file is skipped. A few of these pages could be read before and gave the right result by chance; #59117 lists them. ### How was this patch tested? The 18 tests added in #59117 pass on this branch, as do the existing tests in `ParquetEncodingSuite`, `ParquetVectorizedSuite`, `ParquetColumnIndexSuite`, `ParquetIOSuite`, `ParquetV1QuerySuite`, `ParquetV2QuerySuite`, `ParquetDeltaByteArrayEncodingSuite`, `ParquetDeltaLengthByteArrayEncodingSuite` and `VectorizedPlainValuesReaderSuite`. ``` build/sbt 'sql/testOnly *VectorizedRleValuesReaderSuite *ParquetEncodingSuite *ParquetVectorizedSuite *ParquetIOSuite' ``` ### Was this patch authored or co-authored using generative AI tooling? Generated-by: Claude Code (Claude Opus 5.5) Closes #59482 from viirya/SPARK-59832-branch-4.2. Authored-by: Liang-Chi Hsieh <[email protected]> Signed-off-by: Dongjoon Hyun <[email protected]>
…ized Parquet reader ### What changes were proposed in this pull request? This is a follow-up of SPARK-59832 (#59117). That change added the page and the column to errors from initializing a data page in `VectorizedColumnReader`. This PR adds the column to the decoding errors that still do not report it: - **Decoding a batch.** `readBatch` wraps a `ParquetDecodingException` thrown while decoding values or levels in a `ParquetDecodingException` that names the page and the column: `could not decode page <page> in col <column>: <original message>`. This covers the `ParquetDecodingException`s of the RLE, PLAIN, DELTA_BINARY_PACKED, DELTA_LENGTH_BYTE_ARRAY, DELTA_BYTE_ARRAY and BYTE_STREAM_SPLIT readers. Only `ParquetDecodingException` is caught, so other exceptions keep their types: `SchemaColumnConvertNotSupportedException` and `SparkUpgradeException`, which `FileDataSourceV2.attachFilePath` dispatches on, and the `RuntimeException` of a vector that cannot grow, which `ParquetStorageFilter.rethrowIfMustPropagate` dispatches on. The reader keeps only the description of the current page, not the page, because for codecs that decompress lazily the page holds the decompressor stream. - **Reading a data page.** parquet-java decompresses ZSTD pages eagerly, inside `PageReader.readPage()`, which was outside every catch. Its `ParquetDecodingException` (`could not decompress page`) now gets the column. Only `ParquetDecodingException` is caught here, because `readPage()` also decrypts pages, and decryption errors are not decoding errors. - **Reading the dictionary.** `readDictionaryPage()` and `Encoding.initDictionary` now share one `try`, so a dictionary page that cannot be read fails with `could not decode the dictionary for <column>` whether it is decompressed eagerly (ZSTD) or when `initDictionary` reads its bytes (SNAPPY, GZIP). Besides `IOException` and `ParquetDecodingException`, it wraps the three runtime exceptions a corrupted dictionary page or page header causes there: 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. Other runtime exceptions are not wrapped, so a bug in the reader is not reported as a corrupt dictionary. - **DELTA_BINARY_PACKED.** `VectorizedDeltaBinaryPackedReader.initFromPage` checked the page value count and the mini block size with `checkArgument`, which throws an `IllegalArgumentException`. These checks now throw a `ParquetDecodingException`, like the block size checks next to them, and the mini block size is checked with integer arithmetic. A mini block bit width above 64, which failed with an `ArrayIndexOutOfBoundsException`, is now rejected with a `ParquetDecodingException` when the mini block is unpacked. The existing page wrappers and the new batch wrapper then add the page and the column. - **An empty column chunk.** The constructor's `totalValueCount == 0` error, which a corrupt footer can cause, now names the column as well. Each wrapper keeps the original message and adds a single layer to the cause chain. These cases are not covered: - An out-of-range dictionary id fails with an `ArrayIndexOutOfBoundsException` from `Dictionary.decodeTo*`, either in `readBatch` or, with lazy dictionary decoding, after `readBatch` returns. Reporting the column for it needs a check of every id against the dictionary size, so it is left to SPARK-60317. - A dictionary page whose declared size or entry lengths do not fit in the page can end in an `OutOfMemoryError` or, for an uncompressed chunk, return bytes of the next page. That is left to SPARK-60316. - A corrupt LZ4_RAW or LZ4 page fails with a `RuntimeException` of the codec library, aircompressor's `MalformedInputException` or lz4-java's `LZ4Exception`, which parquet-java does not turn into an `IOException`. - 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. - With a pushed filter, parquet-java's `DictionaryFilter` reads dictionary pages while it filters row groups, before any `VectorizedColumnReader` exists, so a dictionary page that cannot be read there fails without the column. ### Why are the changes needed? Without the column, a decoding error from a corrupted file does not say which column is affected. For example, a truncated RLE run fails with only `Corrupted RLE data: reading past the end of the encoded values`. When `VectorizedColumnReader` reads a SNAPPY or GZIP data page, a decompression error already reports the column, because the page is decompressed lazily inside the existing catch. ZSTD pages did not report it. ### Does this PR introduce _any_ user-facing change? Yes, in error messages only. The decoding errors above now name the column, and the page for errors while decoding a batch. The corrupt dictionaries above now fail with a `ParquetDecodingException` that names the column instead of an `IndexOutOfBoundsException`, `NegativeArraySizeException` or `UnsupportedOperationException`, which is kept as the cause. The DELTA_BINARY_PACKED checks now throw a `ParquetDecodingException` instead of the `IllegalArgumentException` of the value count and mini block size checks, or the `ArrayIndexOutOfBoundsException` of a bit width above 64, and the page and the column are added to it. A ZSTD dictionary page that cannot be decompressed now fails with `could not decode the dictionary for <column>`. Valid files are not affected. ### How was this patch tested? New tests: - `ParquetVectorizedSuite`: - A truncated RLE dictionary id section and truncated PLAIN values report the page and the column, with the original `ParquetDecodingException` as the cause. - A truncated BINARY dictionary, a negative dictionary size and a dictionary page with a non-dictionary encoding report the column. - A mini block bit width of 65 in a DELTA_BINARY_PACKED page reports the page and the column, like the truncated pages above, and an invalid DELTA_BINARY_PACKED header reports the column. - A column chunk without values reports the column. - `ParquetDeltaEncodingSuite`: mini blocks that are not a multiple of 8 values and a page with no values are rejected with a `ParquetDecodingException`. - `ParquetIOSuite`: a ZSTD data page and a ZSTD dictionary page that cannot be decompressed report the column. The test shares a helper with the SPARK-59832 tests, including their timeout. All new tests fail without this change. Existing suites also pass: `VectorizedRleValuesReaderSuite`, `ParquetEncodingSuite`, `ParquetVectorizedSuite`, `ParquetColumnIndexSuite`, `ParquetIOSuite`, `ParquetV1QuerySuite`, `ParquetV2QuerySuite`, `ParquetDeltaEncodingSuite`, `ParquetDeltaByteArrayEncodingSuite`, `ParquetDeltaLengthByteArrayEncodingSuite`, `VectorizedPlainValuesReaderSuite`, `ParquetVectorUpdaterSuite` and `ParquetCompressionCodecPrecedenceSuite`. ### Was this patch authored or co-authored using generative AI tooling? Generated-by: Claude Code (Claude Opus 5.5) This pull request and its description were written by Isaac. Closes #59542 from viirya/SPARK-59854. Authored-by: Liang-Chi Hsieh <[email protected]> Signed-off-by: Dongjoon Hyun <[email protected]>
…ized Parquet reader ### What changes were proposed in this pull request? This is a follow-up of SPARK-59832 (#59117). That change added the page and the column to errors from initializing a data page in `VectorizedColumnReader`. This PR adds the column to the decoding errors that still do not report it: - **Decoding a batch.** `readBatch` wraps a `ParquetDecodingException` thrown while decoding values or levels in a `ParquetDecodingException` that names the page and the column: `could not decode page <page> in col <column>: <original message>`. This covers the `ParquetDecodingException`s of the RLE, PLAIN, DELTA_BINARY_PACKED, DELTA_LENGTH_BYTE_ARRAY, DELTA_BYTE_ARRAY and BYTE_STREAM_SPLIT readers. Only `ParquetDecodingException` is caught, so other exceptions keep their types: `SchemaColumnConvertNotSupportedException` and `SparkUpgradeException`, which `FileDataSourceV2.attachFilePath` dispatches on, and the `RuntimeException` of a vector that cannot grow, which `ParquetStorageFilter.rethrowIfMustPropagate` dispatches on. The reader keeps only the description of the current page, not the page, because for codecs that decompress lazily the page holds the decompressor stream. - **Reading a data page.** parquet-java decompresses ZSTD pages eagerly, inside `PageReader.readPage()`, which was outside every catch. Its `ParquetDecodingException` (`could not decompress page`) now gets the column. Only `ParquetDecodingException` is caught here, because `readPage()` also decrypts pages, and decryption errors are not decoding errors. - **Reading the dictionary.** `readDictionaryPage()` and `Encoding.initDictionary` now share one `try`, so a dictionary page that cannot be read fails with `could not decode the dictionary for <column>` whether it is decompressed eagerly (ZSTD) or when `initDictionary` reads its bytes (SNAPPY, GZIP). Besides `IOException` and `ParquetDecodingException`, it wraps the three runtime exceptions a corrupted dictionary page or page header causes there: 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. Other runtime exceptions are not wrapped, so a bug in the reader is not reported as a corrupt dictionary. - **DELTA_BINARY_PACKED.** `VectorizedDeltaBinaryPackedReader.initFromPage` checked the page value count and the mini block size with `checkArgument`, which throws an `IllegalArgumentException`. These checks now throw a `ParquetDecodingException`, like the block size checks next to them, and the mini block size is checked with integer arithmetic. A mini block bit width above 64, which failed with an `ArrayIndexOutOfBoundsException`, is now rejected with a `ParquetDecodingException` when the mini block is unpacked. The existing page wrappers and the new batch wrapper then add the page and the column. - **An empty column chunk.** The constructor's `totalValueCount == 0` error, which a corrupt footer can cause, now names the column as well. Each wrapper keeps the original message and adds a single layer to the cause chain. These cases are not covered: - An out-of-range dictionary id fails with an `ArrayIndexOutOfBoundsException` from `Dictionary.decodeTo*`, either in `readBatch` or, with lazy dictionary decoding, after `readBatch` returns. Reporting the column for it needs a check of every id against the dictionary size, so it is left to SPARK-60317. - A dictionary page whose declared size or entry lengths do not fit in the page can end in an `OutOfMemoryError` or, for an uncompressed chunk, return bytes of the next page. That is left to SPARK-60316. - A corrupt LZ4_RAW or LZ4 page fails with a `RuntimeException` of the codec library, aircompressor's `MalformedInputException` or lz4-java's `LZ4Exception`, which parquet-java does not turn into an `IOException`. - 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. - With a pushed filter, parquet-java's `DictionaryFilter` reads dictionary pages while it filters row groups, before any `VectorizedColumnReader` exists, so a dictionary page that cannot be read there fails without the column. ### Why are the changes needed? Without the column, a decoding error from a corrupted file does not say which column is affected. For example, a truncated RLE run fails with only `Corrupted RLE data: reading past the end of the encoded values`. When `VectorizedColumnReader` reads a SNAPPY or GZIP data page, a decompression error already reports the column, because the page is decompressed lazily inside the existing catch. ZSTD pages did not report it. ### Does this PR introduce _any_ user-facing change? Yes, in error messages only. The decoding errors above now name the column, and the page for errors while decoding a batch. The corrupt dictionaries above now fail with a `ParquetDecodingException` that names the column instead of an `IndexOutOfBoundsException`, `NegativeArraySizeException` or `UnsupportedOperationException`, which is kept as the cause. The DELTA_BINARY_PACKED checks now throw a `ParquetDecodingException` instead of the `IllegalArgumentException` of the value count and mini block size checks, or the `ArrayIndexOutOfBoundsException` of a bit width above 64, and the page and the column are added to it. A ZSTD dictionary page that cannot be decompressed now fails with `could not decode the dictionary for <column>`. Valid files are not affected. ### How was this patch tested? New tests: - `ParquetVectorizedSuite`: - A truncated RLE dictionary id section and truncated PLAIN values report the page and the column, with the original `ParquetDecodingException` as the cause. - A truncated BINARY dictionary, a negative dictionary size and a dictionary page with a non-dictionary encoding report the column. - A mini block bit width of 65 in a DELTA_BINARY_PACKED page reports the page and the column, like the truncated pages above, and an invalid DELTA_BINARY_PACKED header reports the column. - A column chunk without values reports the column. - `ParquetDeltaEncodingSuite`: mini blocks that are not a multiple of 8 values and a page with no values are rejected with a `ParquetDecodingException`. - `ParquetIOSuite`: a ZSTD data page and a ZSTD dictionary page that cannot be decompressed report the column. The test shares a helper with the SPARK-59832 tests, including their timeout. All new tests fail without this change. Existing suites also pass: `VectorizedRleValuesReaderSuite`, `ParquetEncodingSuite`, `ParquetVectorizedSuite`, `ParquetColumnIndexSuite`, `ParquetIOSuite`, `ParquetV1QuerySuite`, `ParquetV2QuerySuite`, `ParquetDeltaEncodingSuite`, `ParquetDeltaByteArrayEncodingSuite`, `ParquetDeltaLengthByteArrayEncodingSuite`, `VectorizedPlainValuesReaderSuite`, `ParquetVectorUpdaterSuite` and `ParquetCompressionCodecPrecedenceSuite`. ### Was this patch authored or co-authored using generative AI tooling? Generated-by: Claude Code (Claude Opus 5.5) This pull request and its description were written by Isaac. Closes #59542 from viirya/SPARK-59854. Authored-by: Liang-Chi Hsieh <[email protected]> Signed-off-by: Dongjoon Hyun <[email protected]> (cherry picked from commit 8c9aa48) Signed-off-by: Dongjoon Hyun <[email protected]>
What changes were proposed in this pull request?
This is a follow-up of SPARK-59812. It makes
VectorizedRleValuesReader, which decodes definition/repetition levels, dictionary ids and RLE booleans, fail with aParquetDecodingExceptionwhen the encoded data ends early or a header is invalid.readNextGroup()throws when there is no encoded data left, instead of returning false. Callers never read more values than the page declares, so running out of data always means a corrupt page. All theif (currentCount == 0 && !readNextGroup()) break;loop guards becomeif (currentCount == 0) readNextGroup();. With a bit width of 0,initFromPagealready provides every value of the page, so reachingreadNextGroup()also means reading past the end.readNextGroup()skips runs of length 0, socurrentCountis always positive when it returns. The existing group decoding moves unchanged into areadGroup()helper thatreadNextGroup()calls in a loop. The Parquet spec does not allow runs of length 0; skipping them keeps what the batch paths already did, so no file that could be read before fails because of them.numGroups * 8does not overflow and thatnumGroups * bitWidthbytes are left before allocating the value buffer.initFromPagerejects a level length that is cut off, negative or larger than the bytes left in the page, and a dictionary id bit width above 32.VectorizedColumnReadernow also adds the page or dictionary and the column to aParquetDecodingExceptionthrown while initializing a page (inreadPageV1andreadPageV2) or decoding the dictionary page, as it already did for anIOException, so a corrupt length still reports the column. It wraps it in a newParquetDecodingExceptionthat includes the original message, so the exception type stays the same.DictionaryValuesReader("Attempt to read from empty page"). An all-null page, which reads no ids, still works.The added checks run once per run or page, not per value.
Why are the changes needed?
SPARK-34863 made
readNextGroup()return false at the end of the stream. Before that, it calledin.read(), which throws anEOFExceptionthat was wrapped in aParquetDecodingException. Since then, on a corrupt page:readBatchandreadBatchRepeatedreturn without making progress, andVectorizedColumnReader.readBatchloops forever becauserowsToReadInBatchandvaluesToReadInPagenever change.readBooleansignores the return value, and loops forever at the end of the stream.readIntegerignores the return value and decrementscurrentCountbelow 0, returning the last run's value again. It also returns the value of a run of length 0 instead of skipping it.numGroups * 8andnumGroups * bitWidth(the latter was added by SPARK-56895).in.slicewith a negative length moves aSingleBufferInputStreambackwards, and a large count allocates up to anint[2^31 - 8]buffer before noticing that the bytes are missing.SingleBufferInputStream.sliceBuffersonly rejectslength > remaining, so a negative level length moves the stream backwards and the next section of the page is decoded from the wrong offset.When the encoded data ends early, the non-vectorized reader already fails: parquet-java's
RunLengthBitPackingHybridDecoderthrows "Reading past RLE/BitPacking stream.".This PR does not change how a last bit-packed run that is shorter than its padding is handled. Some non-compliant writers produce it (e.g. Polars before pola-rs/polars#13883). The vectorized reader already rejected it before this change, because
in.slicethrew anEOFException, while parquet-java and Arrow C++ (apache/arrow#47992) accept it when the bytes cover the values that are read. Accepting it is left for a follow-up, SPARK-59853.A bit-packed run whose bytes are all present is still not bounded by the number of values in the page, so a corrupt header can still make the reader allocate a buffer much larger than the page needs. Rejecting such runs would also fail non-compliant pages that decode correctly today (e.g. 9 values written as a run of 3 groups), so bounding them is left for a follow-up.
Does this PR introduce any user-facing change?
Only for corrupt or non-compliant Parquet files: reading a page whose levels, dictionary ids or RLE booleans end early, or have an invalid length, bit width or bit-packed header, now fails with a
ParquetDecodingExceptioninstead of returning wrong values, spinning forever, or allocating a buffer for a bit-packed run whose bytes are missing. Valid files are not affected. Withspark.sql.files.ignoreCorruptFiles=true, the rest of such a file is now skipped.A few of these pages could be read before and give the right result by chance, and now fail:
Selecting rows after the cut, or a dictionary with more than one entry, returns wrong values for the same pages without this PR. With
spark.sql.files.ignoreCorruptFiles=true, such a file is no longer read in full: from the batch that reaches such a page, the rest of the file, including later row groups, is skipped with only a warning in the log.How was this patch tested?
New unit tests in
VectorizedRleValuesReaderSuite. The ones on hand-built dictionary id, boolean and length sections (9 of 14) run both on a single buffer and on the same data split into 1-byte buffers, so that every part of the data longer than 1 byte, including a slice of it, is read from aMultiBufferInputStream. Negative tests check the expected message, and the ones that read values run on a separate thread with a timeout, since some looped forever before this change.readIntegers,skipIntegersandreadInteger; truncated booleans onreadBooleans; truncated definition levels onreadBatch(with and without definition level output, and with row ranges that skip past the truncation); truncated repetition levels onreadBatchRepeated; truncated definition levels onreadBatchRepeated(with and without row ranges); reading past a page with bit width 0.Int.MaxValue) level lengths, and dictionary id bit widths above 32.currentCountto 0 or a negative value, or that would allocate a huge buffer.New end-to-end tests in
ParquetIOSuitecorrupt a written file: a shortened definition level run fails instead of hanging, and an oversized length of the definition levels (V1 page) or of RLE boolean values (V2 page) reports the page and column once. A new test inParquetVectorizedSuitechecks that a corrupted dictionary page reports the column.All the new negative tests fail without this change. Existing suites also pass:
ParquetEncodingSuite,ParquetVectorizedSuite,ParquetColumnIndexSuite,ParquetIOSuite,ParquetV1QuerySuite,ParquetV2QuerySuite,ParquetDeltaByteArrayEncodingSuite,ParquetDeltaLengthByteArrayEncodingSuite,VectorizedPlainValuesReaderSuiteandParquetVectorUpdaterSuite.Was this patch authored or co-authored using generative AI tooling?
Generated-by: Claude Code (Claude Opus 5.5)