Skip to content

Add threshold-driven inline vs self-reference storage for FILE values - #1

Merged
brkyvz merged 2 commits into
brkyvz:fileTypefrom
alkis:selfref-inline-threshold
Aug 6, 2026
Merged

Add threshold-driven inline vs self-reference storage for FILE values#1
brkyvz merged 2 commits into
brkyvz:fileTypefrom
alkis:selfref-inline-threshold

Conversation

@alkis

@alkis alkis commented Aug 5, 2026

Copy link
Copy Markdown

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 a Placement — either inline bytes, or an offset/size pair — and the caller writes whichever FILE group fields it indicates. The choice comes from ParquetProperties.withFileSelfReferenceThreshold(int) (default: the page size), so the same writing code produces either form:

FileValueWriter.Placement placement = fileValueWriter.write(payload);
if (placement.isInline()) {
  group.add("inline", placement.getInlineBytes());
} else {
  group.add("offset", placement.getOffset());
  group.add("size", placement.getSize());
}

content_type and checksum describe 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: offset and size are ordinary column values, and once a value reaches a ColumnWriter it'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 (ConsecutivePartList relies 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 selfReferenceOrdinal wasn'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"). Since offset is already carried in the value, this also deletes the ordinal parameter from five signatures.

decompressUnknownSize was broken for every codec, not just unframed ones. The drain loop terminated on read() == -1, but NonBlockedDecompressorStream throws IOException("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, whose maxUncompressedLength can't recover the size. ZSTD exposes no Decompressor at all (createDecompressor() returns null by 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-hadoop from building at all: 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 be266d31 independently of these changes. Also ran spotless:apply.

Not changed

I'd initially tightened schema validation to require inline whenever offset is declared — uri is optional per value, so a uri+offset+size schema can still emit a self-reference with no inline column chunk to inherit compression/encryption from. But testFileLogicalTypeExternalRangedReferenceWithoutInline and testFileLogicalTypeOffsetWithSize assert 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-column 679/679 pass; parquet-hadoop 761/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-hadoop failure is testEnumEquivalenceNo enum constant Encoding.ALP, an artifact of how I had to build this locally: FileType is unreleased, so I pinned parquet.format.version to a locally installed artifact carrying parquet-format master's parquet.thrift, which defines an ALP encoding the Java Encoding enum 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's LogicalType union stops at 18), and apache/parquet-format#603 is itself still open.

This pull request and its description were written by Isaac.

alkis added 2 commits August 5, 2026 19:59
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
brkyvz merged commit c880902 into brkyvz:fileType Aug 6, 2026
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.

2 participants