Repository navigation
Description
Describe the bug, including details regarding any error messages, version, and platform.
Summary
A corrupt Parquet file produced by a Spark writer (parquet-mr based, parquet 1.15.x, zstd-jni 1.5.2-1) contains data pages whose page header uncompressed_page_size is recorded 2 bytes larger than the bytes the decompressor actually produces. Reading
those pages throws EOFException at page decompression / level decoding time.
Environment
- Writer: Apache Spark (parquet-mr 1.15.2 vendored dependencies), ZSTD compression, dictionary encoding enabled (default).
- zstd-jni: 1.5.2-1 at write time (Spark). parquet 1.15.2 declares zstd-jni.version = 1.5.6-6.
- Reader: paimon vectorized reader (VectorizedColumnReader / VectorizedRleValuesReader) using parquet-mr 1.15.2, zstd-jni 1.5.7-6.
- Schema column: ARRAY<ARRAY> → leaf optional int64 element at path *.list.element.list.element, maxRepetitionLevel=2, maxDefinitionLevel=5, dictionary-encoded (PLAIN_DICTIONARY), V1 data pages (writer version PARQUET_1_0).
Error messages
Reading the affected page fails with:
org.apache.parquet.io.ParquetDecodingException: could not decompress page
at org.apache.parquet.hadoop.ColumnChunkPageReadStore$ColumnChunkPageReader$1.visit(ColumnChunkPageReadStore.java:212)
...
Caused by: java.io.EOFException
at java.base/java.io.DataInputStream.readFully(DataInputStream.java:202)
at org.apache.parquet.bytes.BytesInput$StreamBytesInput.toByteArray(BytesInput.java:399)
at org.apache.parquet.bytes.BytesInput.copy(BytesInput.java:205)
at org.apache.parquet.hadoop.CodecFactory$HeapBytesDecompressor.decompress(CodecFactory.java:178)
at org.apache.parquet.hadoop.ColumnChunkPageReadStore$ColumnChunkPageReader$1.visit(ColumnChunkPageReadStore.java:178)
After patching the header's uncompressed_page_size (3127 → 3125) so decompression succeeds, reading still fails, but inside level decoding:
java.io.EOFException
at org.apache.parquet.bytes.SingleBufferInputStream.sliceBuffers(SingleBufferInputStream.java:134)
at org.apache.parquet.bytes.ByteBufferInputStream.sliceStream(ByteBufferInputStream.java:116)
at org.apache.paimon.format.parquet.reader.VectorizedRleValuesReader.initFromPage(VectorizedRleValuesReader.java:108)
at org.apache.parquet.column.page.DataPageV1.accept(DataPageV1.java:134)
Reproducer / measurements
On the corrupt file (178 MB, 6 row groups, 1431 leaf columns), scanning every page by independently decoding the thrift PageHeader and decompressing the page body with zstd-jni directly (bypassing the reader), only 2 pages are affected, both identical
in shape:
- row group 4, column 139 (55.list.element.list.element), data page 5
- row group 4, column 141 (57.list.element.list.element), data page 1
Both report:
header uncompressed_page_size = 3127
header compressed_page_size = 1877
valueCount = 2898
rlEnc=RLE dlEnc=RLE valEnc=PLAIN_DICTIONARY
zstd actual decompressed bytes = 3125 (delta = +2)
Decompressing with zstd-jni 1.5.2-1 and 1.5.7-6 both yield 3125, so this is not a zstd-jni version artifact — the writer fed 3125 bytes to zstd but recorded 3127 in the header.
When I decompressed according to 3125 and read the page content, I found that the declared data length for the RLE part was 658, but the actual content length was 656. It seems that the RLE part output 2 bytes less
Component(s)
Core
Activity
arnabnandy7 commentedon Aug 13, 2026
@weijietong the ZSTD frame contains 3125 uncompressed bytes, while the Parquet page header says 3127. The embedded RLE metadata also expects two more bytes than are present. This suggests the bytes were lost during compression or while the ZSTD frame was finalized, rather than being calculated incorrectly by the Parquet page writer.
The file was written with zstd-jni 1.5.2-1, even though parquet-java 1.15.2 declares 1.5.6-6. Reading it with a newer zstd-jni version will not help because the two bytes were already missing when the file was created.
Could you try writing the same data again with zstd-jni 1.5.6-6 or, preferably, 1.5.7-12? If the problem still occurs, a small input dataset or the affected page would help us reproduce it and add a regression test.
Unfortunately, changing only
uncompressed_page_sizedoes not repair the file because the page contents are also truncated. The affected files will need to be regenerated from their source data.weijietong commentedon Aug 18, 2026
@arnabnandy7 thanks for the response. I can't reproduce this bug. I can share the trimed bad page if you can find some clue from the bad page.It's hard to say the zstd-jni lost the compressed content or the parquet claimed a wrong larger length number.
bad_page.parquet.zip
arnabnandy7 commentedon Aug 18, 2026
I'll try in sometime and let you know.
arnabnandy7 commentedon Aug 18, 2026
@weijietong I confirmed that two bytes are missing from the final repetition-level bit-packed run. The ZSTD frame is valid and its stored CRC matches, so ZSTD compressed the page in its already-incomplete state.
I also stress-tested the parquet-java 1.15.2 RLE encoder across 128,000 randomized inputs, including direct-buffer slab boundaries and the affected value count of 2898. In every case, the reported encoded size matched the serialized byte count, so I could not reproduce this through the normal encoder path.
This may be a rare buffer lifecycle issue or a difference in Spark’s vendored writer path. It would be helpful to know which
ByteBufferAllocatorSpark used and whether the affected writer task reported retries, executor errors, or memory-pressure warnings.weijietong commentedon Aug 19, 2026
@arnabnandy7 The Paimon's vendor
ParquetWriterdoesn't configured a ByteBufferAllocator. So theRunLengthBitPackingHybridEncoderuse the defaultByteBufferAllocatorfrom theParquetProperties. The write path has no exception. The spark's writer task has no retries and errors, no full gc and oom.edgarRd commentedon Sep 18, 2026
We hit what looks like the same defect on an independent writer stack, with a much larger loss than 2 bytes. Adding it as a second data point.
Environment
c7257b8faff5699e13bbc781679dc03f48c1102a).ByteBufferAllocator, page write checksums enabled.EOFExceptioninDataInputStream.readFullyfromCodecFactory$HeapBytesDecompressor.decompress), DuckDB (ZSTD Decompression failure), Trino (out-of-bounds while decoding levels).The file
485,647,873 bytes, 46,000 rows, 8 row groups, 4,602 leaf columns, all ZSTD. Object size on storage matches the size recorded by the writer, so nothing was truncated in transit.BINARYleaf under several levels of 3-levelLISTgroups (Edit:maxRepetitionLevel = 5, maxDefinitionLevel = 22; bit widths 3 and 5). The column is entirely null in every row group (null_count == num_values), so the page carries only repetition and definition levels:rlEnc=RLE dlEnc=RLE valEnc=PLAIN, no dictionary page, no value bytes. The same column's pages in the other 7 row groups are fine.The page
Header:
type=DATA_PAGE num_values=68593 compressed_page_size=21630 uncompressed_page_size=42334, CRC set.uncompressed_page_size.19,368bytes.17,794, length22,958, and17794 + 4 + 22958 == 40756. It decodes to exactly68,593values.17,790rep-level bytes are present.4 + 19368 + 4 + 22958 == 42334: the writer's size bookkeeping was consistent with 19,368 rep-level bytes, but only 17,790 reached the compressor.Edit: what actually happened to the repetition levels
29 sibling columns in the same row group share the same repeated ancestry and value count. All of them have byte-identical 19,368-byte rep-level streams.
The bad stream is exactly one of those with one contiguous interval removed, with no fuzzy matching:
bad_rep == healthy_rep[:14661] + healthy_rep[16239:]The "impossible" level values I reported earlier (5, 6, 7, 27) came from decoding a run whose middle had been cut out. All 3,129 bytes after the cut match the healthy suffix exactly.
Putting the 1,578 missing bytes back gives a 42,334-byte page whose decoded level histograms exactly match the page's size statistics (
SizeStatistics) recorded by the writer:{0: 5906, 1: 46423, 2: 6675, 3: 9589}{3: 274, 4: 30195, 6: 10268, 10: 27856}The cut ends exactly at a slab boundary
Re-encoding the recovered 68,593 levels with the real
RunLengthBitPackingHybridEncoder(3, 64, 1048576, HeapByteBufferAllocator)reproduces the healthy 19,368-byte stream with this slab layout in itsCapacityByteArrayOutputStream:The deleted interval
[14661, 16239)is the tail of the slab[8120, 16239). If that slab'sposition()is lowered from 8,119 to 6,541 before serialization, parquet-java 1.15.1 + zstd-jni 1.4.8-1 reproduce both the decompressed payload and the 21,630-byte compressed page byte for byte. This is deliberate fault injection, not a natural reproduction.That fits the code:
CapacityByteArrayOutputStream.size()returns the independentbytesUsedcounter, which feeds the RLE length prefix anduncompressed_page_size.writeTo(), however, emitsslab.position()bytes per slab, and nothing checks the two against each other.The slab in question was already full, so its position was 8,119 when the next slab was allocated. The next slab is complete. So the position was lowered after that slab was no longer being written to. I could not find any parquet-java code path that does this:
setBytewrites at an absolute index and doesn't move the position.writeIntoflips the slab and then restores its position.getInternalByteBufferonly applies when there is a single slab.The Paimon page fits the same shape
In the attached
bad_page.parquet, the last bit-packed run of the rep levels has header0x15(10 groups = 20 bytes), but only 18 bytes follow before the def-level prefix03 00 00 00. The sibling column's page has the same 18 bytes plus the final padded group51 00, whichRunLengthBitPackingHybridEncoder.toBytes()writes just before it sets that header. So againbytesUsedcounted bytes that a slab'sposition()didn't cover. Here it's the final slab, the one still being written.What we tried
CodecFactoryZSTD compression (parquet-hadoop 1.15.1) asBytesInput.concat(fromInt(len), rl, fromInt(len), dl, empty()), with sizes varied around the real ones, single-threaded and with 8 concurrent compressors sharing theCodecFactory, on Java 8 and Java 17, with zstd-jni 1.4.8-1 and 1.5.7-6. Thousands of iterations, no size or content mismatch.Takeaways
position()lower than what was written, whilebytesUsedis correct. The header and length prefix are right; the serialized payload is short.write()call would look the same, so this isn't a strict proof.)PLAINwith no value bytes.apacheGH-3712: Force multi-slab layout in page-size regression tests