Skip to content

[SPARK-59832][SQL] Fail on truncated input in the vectorized RLE reader - #59117

Closed
viirya wants to merge 8 commits into
apache:masterfrom
viirya:SPARK-59832-rle-truncated
Closed

viirya wants to merge 8 commits into
apache:masterfrom
viirya:SPARK-59832-rle-truncated

Conversation

@viirya

@viirya viirya commented Sep 28, 2026 •

Copy link
Copy Markdown
Member

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 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. The existing group decoding moves unchanged into a readGroup() helper that readNextGroup() 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.
  • 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 cut off, negative or larger than the bytes left in the page, and a dictionary id bit width above 32.
  • VectorizedColumnReader now also adds the page or dictionary and the column to a ParquetDecodingException thrown while initializing a page (in readPageV1 and readPageV2) or decoding the dictionary page, as it already did for an IOException, so a corrupt length still reports the column. It wraps it in a new ParquetDecodingException that includes the original message, so the exception type stays the same.
  • A read error inside a run header or an RLE value (e.g. the data ends in the middle of a header) now also starts with "Corrupted RLE data".
  • 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.

Why are the changes needed?

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.
  • For repeated columns, when only the definition levels end early, the missing definition levels are silently left as 0, which produces wrong nested values.
  • 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.

When the encoded data ends early, the non-vectorized reader already fails: parquet-java's RunLengthBitPackingHybridDecoder throws "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.slice threw an EOFException, 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 ParquetDecodingException instead of returning wrong values, spinning forever, or allocating a buffer for a bit-packed run whose bytes are missing. Valid files are not affected. With spark.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:

  • Dictionary ids (or RLE boolean values) that end early, when every row after the cut is skipped by column index filtering, e.g. the rest of the page before a row range in a later page. The skip stopped silently at the end of the data. This was possible since 3.3.0 (SPARK-34863); 3.2 failed the same way as this PR.
  • A dictionary-encoded page whose data is empty (no bit width byte, which the spec requires), when all its non-null rows are skipped, or when the dictionary has a single entry, so that decoding every id as 0 happened to be right. This was the behavior since the vectorized reader was added in 2.0.0.

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 a MultiBufferInputStream. 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.

  • 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; truncated definition levels on readBatchRepeated (with and without row ranges); reading past a page with bit width 0.
  • Cut-off, negative and oversized (up to Int.MaxValue) level lengths, and dictionary id bit widths above 32.
  • Bit-packed runs that are truncated, that overflow currentCount to 0 or a negative value, or that would allocate a huge buffer.
  • Data ending inside a run header or an RLE value.
  • 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.

New end-to-end tests in ParquetIOSuite corrupt 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 in ParquetVectorizedSuite checks 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, VectorizedPlainValuesReaderSuite and ParquetVectorUpdaterSuite.

Was this patch authored or co-authored using generative AI tooling?

Generated-by: Claude Code (Claude Opus 5.5)

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 HyukjinKwon left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.lastListCompleted case in readBatchRepeated, which still requires leftInPage > 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. VectorizedColumnReader also creates a new dictionary-id reader for each page, so the early return in initFromPage can't reuse state from an earlier page.
  • Bit width 0. initFromPage provides every value of the page, so throwing in readNextGroup for bitWidth == 0 only fires when reading past the page.
  • Corrupt headers. The overflow check (numGroups > Integer.MAX_VALUE / 8 plus a long byte count checked against available()) runs before any allocation, and (int) totalBytes is 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() and MultiBufferInputStream.read() in parquet-common 1.18.1 throw EOFException at end of stream, and the existing catch (IOException e) wraps it. These cases also fail with a ParquetDecodingException, 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.MaxValue group count encodes as ff ff ff ff 0f, which reads back as int -1, so header >>> 1 is Int.MaxValue and the run is bit-packed. The new negative tests fail against the pre-PR readNextGroup, 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") {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit: missing blank line before this test.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed, thanks.

@dongjoon-hyun dongjoon-hyun left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

A few nits:

  • VectorizedRleValuesReader L1038: 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.
  • VectorizedRleValuesReader L1013-1016: when only numGroups > Integer.MAX_VALUE / 8 is true, the message can read "needs 268435456 bytes, but only 268435460 bytes are left". Maybe split the two conditions.
  • VectorizedRleValuesReader L969: case 0 -> 0 in readIntLittleEndianPaddedOnBitWidth is now unreachable because of the new bitWidth == 0 guard.
  • VectorizedRleValuesReaderSuite L296: a fresh OnHeapColumnVector is zero-filled, so this assertion passes even if nothing is written. Filling it with a sentinel first would make it meaningful.
  • VectorizedRleValuesReaderSuite L283: casting to the package-private ParquetReadState bypasses the ParquetTestAccess bridge 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()) {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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") {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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()) {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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(

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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`.
@viirya

viirya commented Sep 29, 2026

Copy link
Copy Markdown
Member Author

@dongjoon-hyun Thanks, addressed the nits:

  • A read error inside a run header or RLE value now reports "Corrupted RLE data: failed to read from input stream".
  • The two bit-packed checks are split, each with its own message.
  • Removed the unreachable case 0 -> 0.
  • The bit width 0 test fills the vector with -1 before reading.
  • ParquetTestAccess has a readBatchRepeated bridge, and the tests use it instead of the cast.

}
private void readNextGroup() {
do {
if (bitWidth == 0 || in.available() <= 0) {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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");
}

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 dongjoon-hyun left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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:

  • VectorizedRleValuesReaderSuite passes (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 ..."), an EOFException for a truncated length prefix, and the IllegalArgumentException mentioned 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 VectorizedRleValuesReaderSuite and ParquetTestAccess don't exist on branch-4.1/4.0.

Follow-up candidates (pre-existing, not blocking):

  1. An RLE run value wider than the bit width is still accepted. With definition levels C8 01 03 (one byte off from C8 01 01), the vectorized reader returns 100 NULL rows without an error, while spark.sql.parquet.enableVectorizedReader=false fails. A per-run check in readGroup() such as bitWidth < 32 && (currentValue >>> bitWidth) != 0 would catch it.
  2. VectorizedColumnReader.readBatch still has a silent exit: if (pageValueCount < 0) break; also leaves the loop when rowsToReadInBatch > 0. With a column-index-filtered read and a page header whose num_values is 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.
  3. The dictionary-page wrapper in the VectorizedColumnReader constructor (L131-136) still catches only IOException, 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, then readPageV1/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. Keeping catch (IOException e) at L366 and widening the catch in readPageV2 (L455) instead gives one "could not read page ... in col ..." layer and a 4-element chain for both V1 and V2.
  • interceptCorrupted runs the read on ExecutionContext.global. An OutOfMemoryError never completes the Future, so it shows up as a 30s TimeoutException, and the suite now logs POSSIBLE THREAD LEAK ... scala-execution-context-global-N. A dedicated ThreadUtils.newDaemonSingleThreadExecutor with submit(...).get(30, SECONDS) avoids both, and the scalastyle:off awaitresult.
  • VectorizedRleValuesReaderSuite L117 and L155 still say the buffer growth and the slice position are "in readNextGroup"; that code is in readGroup() now. The class Scaladoc could also mention the corrupt-input tests.
  • intLE and varint duplicate BytesUtils.intToBytes and BytesInput.fromUnsignedVarLong(v).toByteArray from 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);

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Since the generic IOException fallback now has the same "Corrupted RLE data" prefix, this assertion can't tell which check rejected the input, so the two new pre-allocation checks aren't pinned:

  • Without the (long) numGroups * bitWidth > in.available() check, the suite still passes with a heap of 5g or more: the 1L << 27 case really allocates int[1 << 30] and then fails in in.slice with the same prefix. With the default -Xmx4g it fails only after 30s with a TimeoutException, because the OutOfMemoryError never completes the Future.
  • Without the numGroups > Integer.MAX_VALUE / 8 check, all tests pass: both overflow inputs are also rejected by the byte-count check.

Could interceptCorrupted take the expected message fragment, e.g. "needs 134217728 bytes, but only 3 bytes are left", "is too long", "reading past the end"? For the same reason, the bit-width-0 test could use a small header for trailing (e.g. Array[Byte](3, 1, 2, 3)), so that a regression of that guard fails with an assertion instead of a 4 GiB allocation.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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());

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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()) {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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();

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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");
}

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done, with a test.

*/
private void readGroup() {
try {
int header = readUnsignedVarInt();

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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, but readUnsignedVarInt() 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 above 0x0F in 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 int32 with a 3-entry dictionary and the id section [02 0A 03] (an RLE run of five id 3), readBatch returns normally, and the ArrayIndexOutOfBoundsException is thrown later from ParquetDictionary.decodeToInt when a downstream operator reads the vector. That is outside FileScanRDD.hasNext, so the error has no file path and spark.sql.files.ignoreCorruptFiles doesn'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.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

HyukjinKwon

This comment was marked as outdated.

- 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.
@viirya

viirya commented Oct 4, 2026

Copy link
Copy Markdown
Member Author

@HyukjinKwon Thanks for the re-review. The three test items are addressed in 1a2868d: interceptCorrupted checks the expected message, there's a test for data ending inside a run header or an RLE value, and the V2 case is covered end to end. For the last one I moved the catch from initDataReader to readPageV2 (following dongjoon's double-wrapping nit), and the new test checks that the RLE boolean length error in a V2 page reports the page and column once.

@dongjoon-hyun Thanks for the thorough check. Addressed in 1a2868d:

  • The dictionary page catch now also wraps ParquetDecodingException (your item 3).
  • Moved the ParquetDecodingException catch from initDataReader to readPageV2, so V1 and V2 both have one "could not read page" layer.
  • interceptCorrupted runs on a dedicated cached thread pool with get(30, SECONDS), so an OutOfMemoryError fails right away, the thread leak warning is gone, and the scalastyle:off is no longer needed. I used a cached pool rather than a single thread so that a read stuck in a loop doesn't block the following tests.
  • Updated the stale readNextGroup comments and the class Scaladoc, and switched to BytesUtils.intToBytes and BytesInput.fromUnsignedVarLong.
  • Fixed the description: 7 of the 14 unit tests go through streams.
  • On the release notes: I added the cases that could be read before and now fail to the description. Besides the empty dictionary id page, truncated dictionary ids have also read correctly by chance since 3.3.0 when every row after the cut is skipped by column index filtering.

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 0x03 reads as true today, which is likely what the writer meant, and would fail with it. Like the run count cap and the header length limit, I'd rather keep checks that can reject a stream that decodes correctly today out of this PR, and look at them, and item 2, in follow-ups.

@dongjoon-hyun dongjoon-hyun left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 initFromPage is a SingleBufferInputStream or a MultiBufferInputStream, and in parquet-java 1.18.1 both report the exact number of remaining bytes in available(), 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

  1. VectorizedColumnReader L435: the widened catch also wraps the ParquetDecodingException that SPARK-59831 (merged to master after this PR's base) throws from the BYTE_STREAM_SPLIT reader, and readPage() rethrows it as a RuntimeException. So master's ParquetVectorizedSuite test for SPARK-59831 fails. The same test is on branch-4.x and branch-4.3.

Error reporting

  1. VectorizedColumnReader L455: a type-preserving ParquetDecodingException wrap that includes e.getMessage() would fix item 1, avoid three different shapes for the same kind of corruption, and fit SPARK-59854.
  2. VectorizedColumnReader L436: 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 the ParquetDecodingException from the other readers.
  3. VectorizedColumnReader L131 (pre-existing): decompression errors from readDictionaryPage() and readPage() are outside these catches, so a corrupted ZSTD or LZ4_RAW page still reports no column, while SNAPPY and GZIP do.

Description and limits

  1. VectorizedRleValuesReader L1031 (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.
  2. VectorizedRleValuesReader L1003: with ignoreCorruptFiles=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

  1. VectorizedColumnReader L134: no test covers the widened dictionary catch.
  2. VectorizedRleValuesReaderSuite L74: a timed-out read keeps spinning, so a regression would leave busy threads in the shared sql/core test JVM.
  3. ParquetIOSuite L2546: a cause with a null message turns the assertion into a bare NullPointerException.
  4. ParquetIOSuite L2521: the last Await.result on ExecutionContext.global under scalastyle:off awaitresult; failAfter with a ThreadSignaler is a drop-in.
  5. VectorizedRleValuesReaderSuite L65: the doc leaves out the "Corrupted RLE data: " prefix, which three tests also write by hand.

Nits

  1. VectorizedRleValuesReaderSuite L473: the test counts in the description.
  2. VectorizedRleValuesReaderSuite L232: the same read-and-intercept block appears three times.
  3. ParquetIOSuite L2536: BytesUtils.intToBytes for setMaxLength.
  4. VectorizedRleValuesReaderSuite L58: awaitTermination after shutdownNow().

initDataReader(pageValueCount, page.getValueEncoding(), in);
return pageValueCount;
} catch (IOException e) {
} catch (IOException | ParquetDecodingException e) {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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().

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 ParquetIOSuite test, come from readBatch and reach FAILED_READ_FILE as a bare ParquetDecodingException without 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.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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()) {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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, allocates int[2^29] (2 GiB) and unpacks about 537M ids to return one. With a 1g heap that is an OutOfMemoryError, which ignoreCorruptFiles doesn't cover.
  • branch-3.5 has 4e61adc ("Bounds-check RLE/bit-packed group counts in the vectorized Parquet reader", not released yet). It keeps pageValueCount and rejects runs longer than the page (count > pageValueCount + 7 for 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?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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] = {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 =

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done.


override def afterAll(): Unit = {
try {
executor.shutdownNow()

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Added awaitTermination(10, SECONDS) after shutdownNow().

@dongjoon-hyun dongjoon-hyun left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

  1. VectorizedRleValuesReader L122: 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

  1. VectorizedRleValuesReaderSuite L435: runs of length 0 are tested only on a single buffer, not through streams.

Cleanup

  1. ParquetIOSuite L2511: indexOfSlice instead of a slice per byte offset.
  2. ParquetIOSuite L2532: ExceptionUtils.getThrowableList instead 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,

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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") {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done, with indexOfSlice and the uniqueness check you suggested.

error
}

private def causes(e: Throwable): Seq[Throwable] =

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done.

@dongjoon-hyun

Copy link
Copy Markdown
Member

@viirya, FYI, the CI run on the latest commit, 517133c, still fails because of item 1 of my earlier review. Build modules: sql - other tests has one test failure, and all the other jobs pass:

ParquetVectorizedSuite: SPARK-59831: BYTE_STREAM_SPLIT page of a required column with a wrong value count
org.scalatest.exceptions.TestFailedException: Expected exception org.apache.parquet.io.ParquetDecodingException to be thrown, but java.lang.RuntimeException was thrown

The widened catch (IOException | ParquetDecodingException e) in VectorizedColumnReader.readPageV1/readPageV2 also wraps the ParquetDecodingException that VectorizedByteStreamSplitValuesReader.initFromPage throws (SPARK-59831), and readPage() then rethrows it as a RuntimeException. The same test is on branch-4.x and branch-4.3, so the backports would fail the same way. Could you keep the exception type in that catch (item 2) or update that test in this PR?

viirya and others added 2 commits October 9, 2026 01:16
- 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]>
@viirya

viirya commented Oct 9, 2026

Copy link
Copy Markdown
Member Author

@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 ParquetDecodingException type, so the SPARK-59831 test passes. bf06bd8 also addresses items 2, 3 and 7-15, and 7cdea62 addresses items 16-19. The suites listed in the description, including ParquetVectorizedSuite, pass locally. Items 4 and 5 are left for follow-ups (SPARK-59854 and the bit-packed run bound), and the description is updated for items 5, 6 and 12.

@dongjoon-hyun dongjoon-hyun left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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:

  1. VectorizedColumnReader.java L157: A cut-off BINARY dictionary still loses the column

    (Pre-existing) parquet-java throws an IndexOutOfBoundsException for it, which passes through both catches. The new test covers only the INT32 case.

  2. VectorizedRleValuesReader.java L1010: The empty page message points at the whole page

    When this fires, the page has its levels and only the dictionary id section is empty.

  3. VectorizedColumnReader.java L462: Two shapes for errors from initDataReader

    The inner IOException wrap in initDataReader (L390-392) now duplicates the wrap here and in readPageV2.

  4. VectorizedRleValuesReaderSuite.scala L443: PR description

    9 of the 14 SPARK-59832 tests now go through streams, not 8.

  5. ParquetVectorizedSuite.scala L547: 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.

  6. VectorizedRleValuesReaderSuite.scala L60: lazy val executor

    afterAll always forces it, so a plain val would be equivalent and simpler.

  7. VectorizedRleValuesReader.java L1008: The emptyPage check can move above the loop

    The 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) {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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");

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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") {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done.

}
private void readNextGroup() {
do {
if (emptyPage) {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 dongjoon-hyun left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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:

  1. VectorizedColumnReader.java L388: The new comment says "any error"

    readPageV1 and readPageV2 only wrap an IOException or a ParquetDecodingException. The checkArgument calls in VectorizedDeltaBinaryPackedReader.initFromPage still pass through without the column.

  2. VectorizedColumnReader.java L458: The missing dictionary error still gets two page layers

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

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done, the inner message is now "the dictionary was missing for encoding ...", so the page and column are added once.

@dongjoon-hyun dongjoon-hyun left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

+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]>
@viirya viirya closed this in 605deea Oct 14, 2026
@viirya

viirya commented Oct 14, 2026

Copy link
Copy Markdown
Member Author

Merge Summary:

Posted by merge_spark_pr.py

@viirya

viirya commented Oct 14, 2026

Copy link
Copy Markdown
Member Author

Thanks @dongjoon-hyun @HyukjinKwon !

viirya added a commit that referenced this pull request Oct 15, 2026
… 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]>
viirya added a commit that referenced this pull request Oct 15, 2026
… 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]>
dongjoon-hyun pushed a commit that referenced this pull request Oct 15, 2026
… 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]>
dongjoon-hyun pushed a commit that referenced this pull request Oct 15, 2026
… 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]>
dongjoon-hyun pushed a commit that referenced this pull request Oct 15, 2026
… 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]>
dongjoon-hyun pushed a commit that referenced this pull request Oct 20, 2026
…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]>
dongjoon-hyun pushed a commit that referenced this pull request Oct 20, 2026
…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]>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants