Add threshold-driven inline vs self-reference storage for FILE values - #1
Merged
Merged
Conversation
Writers hand FILE payloads to FileValueWriter, which decides from a configured threshold whether to keep the bytes inline or store them out of line as a self-reference. Both forms describe the same logical bytes, so content_type and checksum are written identically either way and consumers see no difference beyond which fields are set. The payload is written eagerly, while the record is being written and before the row group's column chunks are flushed. This is what makes the offset knowable in time: offset and size are ordinary column values, and once a value reaches a column writer it is encoded into a buffered page and cannot be revised, so a placeholder could not be patched up later. Writing at that point also leaves each column chunk contiguous on disk, which the read path relies on when coalescing chunks into range reads. Along the way, per parquet-format#603: - Key the self-reference AAD on the file offset rather than a synthetic per-chunk counter. The spec defines AAD suffix field 6 as the offset, and the counter was not recoverable from the file: nothing stores it, so a reader had to walk preceding values to rebuild it, defeating the offset-based design that lets a value be resolved on its own. The ordinal parameter is gone from five signatures as a result. - Decompress into a dynamically sized buffer, supporting every codec. The previous stream-drain loop was broken for all codecs, not just unframed ones: NonBlockedDecompressorStream throws rather than returning -1 once its block is consumed. - Enforce the 2 GiB encrypted-module limit on write, directing oversized values to external references. - Require inline whenever offset is declared. uri is optional per value, so a uri+offset+size schema still permits self-references, and one emitted without an inline column chunk has no reference point to inherit compression and encryption from. Tests cover threshold routing, offset-keyed AAD round-trips in both GCM and CTR modes, tamper detection when an offset is altered, and payloads spanning several buffer doublings across SNAPPY, GZIP, ZSTD and LZ4_RAW. Co-authored-by: Isaac
Verified the change by building and running the suites locally, which turned up three things: ZSTD self-references could not be resolved at all. ZstandardCodec returns null from createDecompressor because it decompresses only through its stream, so driving the Decompressor directly failed with "Could not obtain a decompressor". Such codecs are framed and report end-of-input properly, so drain the stream for them and keep grow-and-retry for the rest. Four tests were failing on this. Reverted requiring `inline` whenever `offset` is declared, back to requiring it only when `uri` is absent. Two existing tests (testFileLogicalTypeExternalRangedReferenceWithoutInline, testFileLogicalTypeOffsetWithSize) assert that a uri+offset+size schema without `inline` is valid, so the stricter rule contradicted the author's documented intent. The concern is real -- `uri` is optional per value, so such a schema can still emit a self-reference with no reference point -- but it belongs on the write path, and the FILE group declaring `uri` is now documented as needing an always-inline threshold. Fixed two pre-existing test compile errors that blocked the module: TestParquetMetadataConverter used assertEquals/assertTrue with no JUnit import (switched to the AssertJ style used throughout that file), and TestSelfReferenceFileWrite was missing the ParquetReadOptions import. Both fail on the base commit independently of these changes. Also applied spotless formatting. Local results: parquet-column 679/679 pass, parquet-hadoop 761/762. The one failure, testEnumEquivalence on Encoding.ALP, is an artifact of the local workaround for FileType being unreleased -- parquet.thrift was substituted from parquet-format master, which defines an ALP encoding the Java enum does not yet have. It is unrelated to these changes and will not occur once a parquet-format release carries FileType. Co-authored-by: Isaac
brkyvz
approved these changes
Aug 6, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Builds on apache#3608 (based on
brkyvz:fileType, so the delta here is just the two commits on top).Adds the inline-vs-self-reference decision to the writer, and fixes a few things found while verifying it against the revised spec in apache/parquet-format#603.
Threshold-driven storage
FileValueWriter.write(payload)returns aPlacement— either inline bytes, or anoffset/sizepair — and the caller writes whicheverFILEgroup fields it indicates. The choice comes fromParquetProperties.withFileSelfReferenceThreshold(int)(default: the page size), so the same writing code produces either form:content_typeandchecksumdescribe the resolved bytes either way, so they're written identically and consumers see no difference beyond which fields are set.Payloads are written eagerly, mid-record, before the row group's column chunks flush. That isn't a choice:
offsetandsizeare ordinary column values, and once a value reaches aColumnWriterit's encoded into a buffered page and can't be revised — so a placeholder offset could never be patched up later. Writing at that point also puts payloads in a contiguous run between row groups, which keeps each column chunk contiguous on disk (ConsecutivePartListrelies on that when coalescing chunks into one range read).Fixes
Self-reference AAD keyed on the file offset, not a synthetic counter. Field 6 of the AAD suffix is defined as the offset of the self-reference within the file. The previous
selfReferenceOrdinalwasn't recoverable from the file — nothing stores it, so a reader had to walk the preceding values to rebuild it, which defeats the property the offset-based AAD exists to provide (§ "available to a reader without counting preceding values... may therefore resolve a self-reference without decoding the pages it skips"). Sinceoffsetis already carried in the value, this also deletes the ordinal parameter from five signatures.decompressUnknownSizewas broken for every codec, not just unframed ones. The drain loop terminated onread() == -1, butNonBlockedDecompressorStreamthrowsIOException("Corrupt file: Zero bytes read during decompression.")once its block is consumed instead of returning-1. Now: grow-and-retry into a dynamically sized buffer (2× compressed size, doubling), per the spec's "decompress into a dynamically sized buffer". All codecs work, including LZ4_RAW, whosemaxUncompressedLengthcan't recover the size. ZSTD exposes noDecompressorat all (createDecompressor()returnsnullby design) and decompresses only through its stream, which is framed — so that case drains the stream. I tried collapsing both paths into a single stream loop; it fails 8/22 tests, so the split is load-bearing.2 GiB encrypted-module cap enforced on write, pointing oversized values at external references, rather than silently writing a corrupt length field.
Two pre-existing test compile errors that prevented
parquet-hadoopfrom building at all:TestParquetMetadataConverterusedassertEquals/assertTruewith no JUnit import (switched to the AssertJ style used throughout that file), andTestSelfReferenceFileWritewas missing theParquetReadOptionsimport. Both fail onbe266d31independently of these changes. Also ranspotless:apply.Not changed
I'd initially tightened schema validation to require
inlinewheneveroffsetis declared —uriis optional per value, so auri+offset+sizeschema can still emit a self-reference with noinlinecolumn chunk to inherit compression/encryption from. ButtestFileLogicalTypeExternalRangedReferenceWithoutInlineandtestFileLogicalTypeOffsetWithSizeassert that schema is valid, so I reverted to your semantics and documented the write-path expectation instead. Worth a decision either way — happy to follow whichever you prefer.Testing
parquet-column679/679 pass;parquet-hadoop761/762; the 35 self-reference tests all pass. New coverage: threshold routing, offset-keyed AAD round-trips under AES_GCM_V1 and AES_GCM_CTR_V1, tamper detection when an offset is altered, identical payloads at different offsets not being interchangeable, and payloads spanning several buffer doublings (including exact powers of two, empty, and incompressible) across SNAPPY/GZIP/ZSTD/LZ4_RAW.The one
parquet-hadoopfailure istestEnumEquivalence→No enum constant Encoding.ALP, an artifact of how I had to build this locally:FileTypeis unreleased, so I pinnedparquet.format.versionto a locally installed artifact carrying parquet-format master'sparquet.thrift, which defines anALPencoding the JavaEncodingenum doesn't have yet. Unrelated to these changes.Note this stack still can't go green on CI until a parquet-format release carries
FileType(2.13.0'sLogicalTypeunion stops at 18), and apache/parquet-format#603 is itself still open.This pull request and its description were written by Isaac.