fix(parquet): prevent batch-induced oversized BYTE_ARRAY pages - #747
Conversation
| if (data_encryptor_.get()) { | ||
| PARQUET_THROW_NOT_OK(encryption_buffer_->Resize( | ||
| data_encryptor_->CiphertextSizeDelta() + output_data_len, false)); | ||
| data_encryptor_->CiphertextSizeDelta() + compressed_page_size, |
There was a problem hiding this comment.
When the compressed page is close to INT32_MAX, this addition can overflow before the post-encryption checkPageHeaderSize() is reached. The same issue exists in the dictionary-page path.
There was a problem hiding this comment.
Thanks, fixed both data and dictionary paths by validating the encrypted buffer size in int64 before allocation and encryption. Regression tests were added for both paths.
| std::unique_ptr<PageWriter> pageWriter_; | ||
| }; | ||
|
|
||
| TEST_F(PageWriterSizeGuardTest, rejectsUncompressedDataPageSizeOverflow) { |
There was a problem hiding this comment.
These tests verify the final PageWriter guard, but they do not exercise the BYTE_ARRAY splitter against the PageHeader hard limit.
There was a problem hiding this comment.
Added a ColumnWriter test with a reduced PageHeader limit. It writes real Arrow VARCHAR batches with the configured page size above the hard limit and verifies both PLAIN and dictionary/fallback paths keep emitted pages within that limit. Production still uses INT32_MAX.
| // the hard format boundary, so cap byte-budget arithmetic at INT32_MAX and | ||
| // leave the final encoded/compressed size checks to PageWriter. | ||
| auto PageByteLimit = [](int64_t configured_limit) { | ||
| return std::clamp<int64_t>(configured_limit, 0, kMaxPageHeaderSize); |
There was a problem hiding this comment.
Is it more reasonable to throw an exception when configured_limit is not within the range?
There was a problem hiding this comment.
data_pagesize is an existing best-effort flush target rather than a hard user-visible limit. I kept the configured value accepted and only cap the internal BYTE_ARRAY budget at the PageHeader int32 boundary, adding a throw here would introduce behavior change.
| &null_count); | ||
|
|
||
| // For PLAIN encoding this is the data-page value size. During dictionary | ||
| // encoding it is a conservative upper bound on dictionary growth: duplicate |
There was a problem hiding this comment.
When cardinality is low, using the plain-encoded size to estimate the deduplicated dictionary size can introduce an order-of-magnitude error. Is this reasonable?
There was a problem hiding this comment.
The estimate is intentionally conservative and follows the same policy as arrow-rs #9972. Its merged implementation uses the remaining dictionary-page budget and estimates ordinary Utf8/Binary input from its PLAIN-encoded size. Arrow DictionaryArray input is handled separately because its values are already deduplicated (code and comment).
For low-cardinality input this may cause additional subchunk work, but fallback still checks the actual dict_encoded_size(), so the conservative estimate itself does not cause false fallback. repeatedByteArrayValuesDoNotForceDictionaryFallback covers this case.
| // The offset range is exact for canonical Arrow binary arrays and a | ||
| // conservative upper bound if a null slot retains bytes. | ||
| const int64_t page_byte_limit = CurrentPageByteLimit(); | ||
| const int64_t batch_encoded_bytes = PlainEncodedRangeSize( |
There was a problem hiding this comment.
When using DELTA_SYTE-ARRAY encoding, batch_encoded_bytes uses PLAIN size while CurrentPageBytes() uses actual DELTA size. This can greatly overestimate repeated or prefix-similar strings and create excessive pages。
There was a problem hiding this comment.
Thanks. I separated the PLAIN/raw byte budget from the actual encoded page size. DELTA now uses the raw estimate only to bound each encoder Put(), while page flushing is based on EstimatedDataEncodedSize(). PLAIN and dictionary keep their remaining-budget behavior. I also fixed DELTA_LENGTH_BYTE_ARRAY Arrow payload accounting and added compressible/incompressible DELTA page-layout and round-trip tests.
| batch_num_spaced_values, | ||
| batch_num_values, | ||
| page_byte_limit); | ||
| if (CappedPageBytes( |
There was a problem hiding this comment.
I guess there are many cases online where batch_decoded_bytes are greater than 1M but less than INT32 MAX, which cannot hit this fast path. Please sample the online situation for performance testing and consider whether it is necessary to increase the default 1M page size
There was a problem hiding this comment.
Thanks. I validated the fix with the affected online cases, but stable reproducible samples are limited because some failures are intermittent. I’ll continue monitoring and collecting samples.
I prefer to keep the default 1 MiB page target unchanged in this PR. Increasing it would raise per-page memory usage and only move the threshold rather than solve oversized batches. Any default-size tuning should be evaluated separately with representative workload data.
0b2ebb0 to
f19f730
Compare
What problem does this PR solve?
The Parquet writer could produce oversized data pages for
BYTE_ARRAYcolumns, includingVARCHARandVARBINARY.Previously, a complete write batch was appended to the encoder before checking the configured data page size. A batch containing large variable-width values could therefore make the buffered page significantly exceed the page-size target. In extreme cases, the compressed or uncompressed page size could exceed the
int32fields in the ParquetPageHeader, producing invalid Parquet files and causing excessive reader-side allocations.The same problem could occur after dictionary encoding falls back to PLAIN encoding.
Issue Number: close #612
Type of Change
Description
This change prevents batch-induced oversized Parquet
BYTE_ARRAYpages before values are appended to the page encoder.The writer now:
BYTE_ARRAYchunk using Arrow binary offsets before calling the encoder.PageHeadersize validation for the hard Parquet format limit.The no-split fast path applies to flat, nullable, and nested
BYTE_ARRAYcolumns. It reuses the validity metadata required by the existing write path and avoids an additional per-level value-length scan for normal chunks.Dictionary encoding is also covered:
int64_t.As a final safety boundary, compressed, uncompressed, and encrypted data/dictionary page sizes are validated before narrowing them to the Parquet
PageHeaderint32fields.Tests cover:
VARCHAR.VARBINARY.ARRAY<VARCHAR>with parent and element nulls.BYTE_ARRAYvalue.PageHeadersize overflow guards.The Parquet writer benchmark was also updated to:
ARRAY<VARCHAR>, and normaldictionary-encoded inputs.
Performance Impact
No Impact: This change does not affect the critical path (e.g., build system, doc, error handling).
Positive Impact: I have run benchmarks.
Negative Impact: Targeted benchmarks show up to 3.22% overhead for
short VARCHAR batches with no actual null values; other tested no-split
cases show no clear regression.
Click to view Benchmark Results
Release Note
Release Note:
Checklist (For Author)
Breaking Changes
No
Yes (Description: ...)
Click to view Breaking Changes