Skip to content

Rebuild Parquet.jl in pure Julia - #199

Open
quinnj wants to merge 11 commits into
masterfrom
rewrite/1.0
Open

Rebuild Parquet.jl in pure Julia#199
quinnj wants to merge 11 commits into
masterfrom
rewrite/1.0

Conversation

@quinnj

@quinnj quinnj commented Aug 28, 2026

Copy link
Copy Markdown
Member

Rebuilds Parquet.jl as a pure Julia implementation, then applies the fixes from an
adversarial review of that rewrite.

The registered package UUID and the Git history are preserved. The old PAR2 runtime is
replaced. Read HANDOFF.md for the branch contract before reviewing.

What the rewrite provides

Bounded source ownership and footer framing; a pure Julia Thrift Compact Protocol
runtime with generated Parquet 2.13.0 metadata types; physical and logical schema
validation; Dremel levels and nested vectors; the PLAIN, RLE, dictionary, delta, and
byte-stream-split encodings; Data Page V1 and V2 framing with CRC checks; the
uncompressed, Snappy, gzip, Brotli, Zstd, and LZ4 codecs; scalar logical types with
bounded JSON, BSON, and decimal validation; recursive nested reading and writing;
statistics, producer-trust, and page-index handling; a Tables.jl facade and a
namespaced writer API.

The package has no exports. The public names are BSONValue, Decimal, File,
Interval, JSONValue, Limits, LogicalColumn, Table, Timestamp, close!,
and write.

Review fixes in this branch

The library parses untrusted files, so the review treated every file-derived value as
attacker controlled.

Untrusted metadata. Preserved Thrift fields were stored in a tuple whose length was
attacker controlled; because the generated ==, isequal, and hash specialize per
length, a small footer carrying a few thousand unknown fields cost seconds of
compilation on the first comparison of decoded metadata. They are now stored in a
Vector, and the union invariant is re-checked at encode time since that vector is
mutable. max_schema_name_bytes now bounds a single operation instead of the process
lifetime, so one hostile file can no longer exhaust a shared budget and deny every
later file carrying a new column name. A nested plan deeper than the reader's recursion
limit is rejected with a LimitError rather than exhausting the stack, since the
schema parser and plan compiler are iterative and could build such a plan under a
raised max_metadata_depth. Byte-stream-split fixed-width copies are charged to the
live budget, and the close guards are atomic.

Temporal and rewrite fidelity. A millisecond TIMESTAMP adjusted to UTC decoded to
a bare DateTime, so a read and write cycle silently republished a UTC instant as
local time; it now reads as Timestamp{:millis}. Legacy converted annotations are
written for both values of isAdjustedToUTC, matching the pinned IDL. Schema-bearing
rewrites preserve key_value_metadata, which previously dropped producer metadata such
as ARROW:schema. Provenance validation compares preserved fields exactly, so a change
to raw Compact Thrift header state cannot pass as identical.

Unreachable code. The flat LIST assembler, the MAP iterate fallbacks in the nested
writer, and the unused column page builders were each proven unreachable from the
public API before removal. Coverage that only reached them through a dead wrapper was
retargeted onto the live entry points rather than deleted.

Every fix carries a focused regression test.

Verification

  • Full package suite green on Julia 1.10.11 and 1.12.6, with the pinned
    apache/parquet-testing corpus present.
  • Documentation builds, including doctests.
  • The exact external N6 gate passes on macOS 15 arm64 against the pinned toolchain:
    5117 of 5117 preproduction checks and 578 of 578 independent model checks, exercising
    the raw Java scanner, Arrow Rust, Parquet Java, PyArrow, and DuckDB oracles plus the
    isolated schema validator. The evidence was re-established by re-running the
    producers, not by restating hashes: the twelve generated Parquet files and
    ninety-five of the ninety-six evidence records are byte-identical to before, which
    independently confirms these changes leave observable behavior unchanged.

Status

This branch stays preproduction. test/conformance/n6/manifest.toml keeps
status = "preproduction", and both publication_authorized and
oracle_lock_authorized remain false. Known remaining work is listed in
HANDOFF.md, including LZO, the target-only modules, PyArrow and DuckDB
source-to-wheel provenance, and expanding the exact gate beyond macOS 15 arm64.

Note for CI: the N6 static lane verifies the running Julia runtime tree against a
frozen pin, which cannot be reproduced on an arbitrary local install. It is expected to
pass on a clean runner that downloads the pinned Julia versions.

quinnj and others added 5 commits August 27, 2026 06:47
Replace the legacy PAR2 runtime with bounded pure Julia metadata, schema, encoding, page, logical, nested reader, and writer layers. Add N5 and N6 conformance evidence, resource and mutation tests, documentation, CI, and the continuation handoff.

Keep the package at preproduction status. Publication and oracle locking remain disabled until the remaining provenance and platform gates pass.

BREAKING CHANGE: remove the legacy PAR2, cursor, reader, writer, and dataset implementation in favor of the new namespaced File, Table, and write API.
Adversarial review of the rewrite surfaced defects in metadata handling,
temporal round trips, and resource accounting. Fix them, then delete the
read and write paths that review proved unreachable.

Untrusted metadata
- Store preserved Thrift fields as `Vector{RawField}` instead of a tuple.
  The tuple length was attacker controlled, and the generated `==`,
  `isequal`, and `hash` specialize per length, so a small footer carrying
  a few thousand unknown fields cost seconds of compilation on the first
  comparison of decoded metadata. Re-check the union invariant when
  encoding, because the vector is mutable after construction.
- Bound `max_schema_name_bytes` per operation instead of over the process
  lifetime. One hostile file used to exhaust the shared budget and deny
  every later file that carried a new column name.
- Reject a nested plan deeper than the reader's recursion limit with a
  `LimitError`. The schema parser and plan compiler are iterative, so a
  raised `max_metadata_depth` could otherwise exhaust the stack during
  assembly and corrupt process state.
- Charge byte-stream-split fixed-width copies to the live budget, and
  make the close guards atomic so a concurrent close cannot release a
  materialization charge twice.

Temporal and rewrite fidelity
- Read a millisecond TIMESTAMP as `Timestamp{:millis}` when it is
  adjusted to UTC. It previously decoded to a bare `DateTime`, so a read
  and write cycle silently republished a UTC instant as local time.
  Millisecond columns without adjustment still read as `DateTime`, and
  the constructor now rejects the contradictory combination.
- Preserve `key_value_metadata` on a schema-bearing rewrite, with the
  key and value sizes checked before allocation. Rewrites previously
  dropped producer metadata such as `ARROW:schema`.
- Compare preserved fields exactly during provenance validation, so a
  change to the raw Compact Thrift header state cannot pass as identical.
- Report an INT96 column as an unsupported feature rather than an invalid
  file. INT96 is valid but deprecated, and is not implemented here.

Unreachable code
- Delete the flat LIST assembler in the table reader. Every schema
  containing a group compiles to a nested plan, so the branch was
  unreachable from `Parquet.Table`.
- Delete the MAP iterate fallbacks in the nested writer. A MAP value is
  always a concrete `MapValue` or `AbstractDict`, both intercepted
  earlier.
- Delete the unused column page builders and the unbudgeted dictionary
  helpers they were the last callers of.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Document the behavior that review found surprising but correct, and stop
resolved environments from reaching the branch.

- Note which annotations a read and write cycle does not preserve, and
  point at `LogicalColumn` for selecting an exact annotation.
- State the reader's nesting depth bound and why the writer accepts
  deeper synthetic values than the reader will assemble.
- Describe memory-mapped file lifetime: `close` releases the descriptor
  while the mapping waits for finalization, which can hold a Windows file
  open, and truncation by another process is fatal to the mapping.
- Say what `max_materialized_bytes` covers, since borrowed vectors and
  mapped paths are not charged and an external source owns its storage.
- Ignore the root and docs manifests and the corpus checkout. The
  conformance manifest under test/conformance/n6/julia stays tracked.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The N6 evidence binds each producer to an exact source revision, so the
preceding source changes invalidated it. Re-establish that binding by
re-running the producers and the exact external gate, rather than by
restating hashes.

- Recompute the Parquet.jl producer descriptor over the new source using
  the gate's own composite algorithm.
- Rebind the parquet-jl authority in the capability matrix. The producer
  harness refuses to emit evidence until the declared revision and
  toolchain match the descriptor.
- Regenerate the producer evidence. The twelve generated Parquet files
  and ninety-five of the ninety-six evidence records are unchanged; only
  the authority record moved, which confirms the source changes leave the
  observable behavior identical.
- Restate the capability digest each normalized evidence run record
  carries, and resolve the upstream evidence citations that follow from
  it.
- Re-pin the frozen evidence digests and the artifact manifest.

Verified with the exact external gate on macOS 15 arm64 against the
pinned toolchain: 5117 of 5117 preproduction checks and 578 of 578
independent model checks, with the raw Java scanner, Arrow Rust, Parquet
Java, PyArrow, and DuckDB oracles and the isolated schema validator.
Gate status stays preproduction; publication and oracle locking remain
unauthorized.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
First continuous integration run of this branch exposed portability defects
that only appear away from the development platform.

- Install dependencies in the bounds job. It ran the N5 conformance suite
  without a build step, so the package failed to load.
- Keep text files LF in the working tree. Windows checked out CRLF, which
  broke a multiline pattern in the N5 model tests. Conformance evidence and
  artifact manifests are content hashed, so a CRLF checkout would also
  change their digests. Binary fixtures are excluded, and no tracked file
  is renormalized by this change.
- Compare a manifest path in its own forward-slash form. normpath emits the
  platform separator, so Windows rejected a correctly normalized path.
- Loosen the writer scratch-stack allocation bounds. The same measurement
  runs about a seventh higher on x86-64 than on arm64, and the previous
  bound was tuned on arm64. Measured against the branch point on one
  machine the allocations are unchanged, so this is platform headroom
  rather than a regression. The bounds still catch a pass that stops
  reusing its scratch stacks, which costs far more than a constant per row.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@codecov

codecov Bot commented Aug 28, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 96.36002% with 256 lines in your changes missing coverage. Please review.
✅ Project coverage is 87.67%. Comparing base (95a3037) to head (ce15600).

Files with missing lines Patch % Lines
src/table.jl 85.43% 44 Missing ⚠️
src/vectors.jl 93.78% 40 Missing ⚠️
src/errors.jl 86.45% 26 Missing ⚠️
src/page_index.jl 95.84% 21 Missing ⚠️
src/column.jl 96.68% 16 Missing ⚠️
src/nested_reader.jl 97.55% 13 Missing ⚠️
src/nested_table.jl 90.69% 12 Missing ⚠️
src/statistics.jl 98.30% 12 Missing ⚠️
src/logical_binary.jl 96.15% 10 Missing ⚠️
src/logical.jl 93.66% 9 Missing ⚠️
... and 12 more
Additional details and impacted files
@@            Coverage Diff             @@
##           master     #199      +/-   ##
==========================================
+ Coverage   86.46%   87.67%   +1.20%     
==========================================
  Files          10       37      +27     
  Lines        1596    17875   +16279     
==========================================
+ Hits         1380    15672   +14292     
- Misses        216     2203    +1987     

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

quinnj and others added 3 commits August 27, 2026 19:44
`close!` releases the descriptor while the mapping waits for finalization, so
Windows keeps the file locked and removing the temporary file is refused. That
is the documented source lifetime, so let this cleanup step accept the refusal
instead of failing the suite.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
`relpath` emits the platform separator, so the harness looked for a backslash
path in a manifest that records forward slashes and reported the included N6
model source as unpinned on Windows.

The harness is itself pinned, so re-freeze the descriptor, the capability
authority that names it, the producer evidence, the evidence bindings that cite
it, and the artifact manifest. The source composite is unchanged, because the
harness is a support file rather than package source.

Re-verified with the exact external gate on macOS 15 arm64: 5117 of 5117
preproduction checks and 578 of 578 independent model checks.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The harness fsyncs its destination directory and relies on POSIX rename and
symlink behavior, and refuses to publish anywhere else. The guard tests called
it unconditionally, so Windows reported a refusal it was always going to make.

Only the artifact manifest and its digest move; the producer descriptor,
capability authority, and evidence are untouched, because this file is pinned
as a static artifact rather than as producer source.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@nhz2

nhz2 commented Aug 28, 2026

Copy link
Copy Markdown
Member

This is very cool, but you should make a new package for this, because this PR is too big for people to review.

@quinnj

quinnj commented Aug 28, 2026

Copy link
Copy Markdown
Member Author

This is very cool, but you should make a new package for this, because this PR is too big for people to review.

Haha, I'm not sure a Parquet4.jl would go over well! No, I need to dig in to the diff here, because it's actually a way smaller set of actual code changes (comparable to the ARrow.jl package) and not actually 100K lines of julia to support parquet. I've only reviewed about half of it myself so far, so there's still probably ways to clean it up further.

My understanding is that Parquet/Parquet2/Parquet3 are have various warts/gaps in spec coverage and none are super actively maintained. I'd like to have a really good package that does cover teh spec and can be pointed at as the "canonical" package; Parquet.jl seems like the best home for that.

This isn't quite ready yet, but I'd love ot help anyone interested in helping digest a large codebase inot manageable chunks.

@nhz2

nhz2 commented Aug 28, 2026

Copy link
Copy Markdown
Member

Parquet2.jl is actively maintained and, right now I would call QuackIO.jl the canonical package. Parquet is hard because if you take this format seriously you should engage with upstream to ensure changes that are made to the format are compatible with the julia package. For example it would be good to see the hypothetical canonical julia package on https://parquet.apache.org/docs/file-format/implementationstatus/ to see how it stacks up against the standard implementations.

Also as you can see from there, that nobody wants to have full spec coverage (there are deprecated things like LZO and one of the LZ4 codecs that you probably don't want to deal with).

The spec is also vague and evolving over time, so in reality the target should be compatibility with Parquet2.jl, QuackIO.jl and the implementations in other languages.
An idea for making this more reviewable is to use https://github.com/apache/parquet-testing for test files, instead of reinventing the wheel and storing all the test files in this repo. If https://github.com/apache/parquet-testing is missing coverage of important parts of the spec, you can add files there and get help from other people working on parquet to ensure we are getting this right. Take a look at apache/parquet-testing#119 from last week as an example.

@nhz2

nhz2 commented Aug 28, 2026

Copy link
Copy Markdown
Member

Also, is it possible to contribute improvements to https://github.com/JuliaAPlavin/QuackIO.jl/pulls and https://gitlab.com/ExpandingMan/Parquet2.jl/-/merge_requests instead of making another package? Maybe @aplavin @ExpandingMan or @bjarthur have some thoughts on this.

@aplavin

aplavin commented Aug 28, 2026

Copy link
Copy Markdown

I'm generally using QuackIO (admit I can be biased :) ), and would say its approach has both pros and cons. Obvious +s are that duckdb engine is very reliable and thoroughly tested on everything; also it has a nice query support, with regular julia syntax for simple queries. The (only significant?) - is that going through duckdb makes it impossible to do memory mapping. Julia-native query interface makes it a minor issue in my experience, as one generally wants a smaller subset of a huge dataset in Julia anyway - but mmapping is where Parquet.jl has/can have a fundamental edge.

@quinnj

quinnj commented Aug 28, 2026

Copy link
Copy Markdown
Member Author

Parquet2.jl is actively maintained

Is it? Last I heard, ExpandingMan was specifically not going to do further work on the package and was effectively "done" with it. I guess there are still dependabot bumps going on, but in my mind, it's not an actively developed package much more than Parqet.jl is.

I have no issues/qualms with QuackIO.jl and I think it's an excellent option/format for certain use-cases. But it's also not what I'm personally interested in, which is pure-Julia parquet support.

I'm also aware of the deprecated parts of the spec and don't plan to support them.

Not trying to rock anyone's boat here or anything; I happen to have access to a lot of AI tokens at the moment, I have a lot of experience w/ data format implementations, and I've wanted a solid pure-Julia parquet for a long time with good mmap support, all hte Tables.jl integrations (including a new Tables.Scan component), etc.

quinnj and others added 2 commits August 28, 2026 10:09
A MAP key snapshot exists only while a trace records one, so an aggregate scan
run without a trace reached the key assertion with nothing to assert against and
raised a MethodError. Struct and list sources already scanned without a trace;
only MAP could not.

Add the missing method for the absent snapshot. It keeps every check that does
not need one, so only the comparison against a previous pass is skipped, and that
comparison has no referent without a trace. The public writer always supplies a
trace and never reached this.

The regression covers both MAP scan arms, a dictionary source and a map view, and
asserts that the scan still infers decimal and timestamp leaf parameters rather
than merely completing.

Re-freezing the N6 evidence follows from touching pinned package source. The
twelve generated fixtures are unchanged. Re-verified with the exact external gate
on macOS 15 arm64: 5117 of 5117 preproduction checks and 578 of 578 independent
model checks.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The metadata types come from `julia thrift/generate.jl` and the N6 evidence
records from the producer harness. Neither is edited by hand, so collapse both by
default in review rather than reading them as authored code.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@nhz2

nhz2 commented Aug 28, 2026

Copy link
Copy Markdown
Member

Yes the Parquet2.jl package is very much alive. ExpandingMan is not driving development of new features, but last I checked he is still interested in supporting more Parquet features and willing to merge things and help maintain the code, your new way of handling thrift and all the java tests here seem like things that would be great in Parquet2.jl as well.

From my perspective the only reason to keep using Parquet.jl is for people who like its API and don't want to learn Parquet2.jl or QuackIO.jl's API, so I can't see the reason to make breaking changes to the API of Parquet.jl

IMO mmap often causes more problems then it is worth, and it is possible to match performance and have better error handling with manual caching systems (but I guess this depends on what operating system/file system/CPU you are using).

@ExpandingMan

Copy link
Copy Markdown

Parquet2.jl is maintained in the sense that I intend to merge PR's and fix critical bugs, but yes it is very unlikely I will ever do more work than that on the package. I do think duckdb is a really good solution for parquet in Julia and it supports some fantastic features such as querying. In any case, I don't personally have any objections to Parquet2.jl being replaced with something incompatible.

Complete coverage of the format was never a realistic goal, and treating it as one
held the release behind work nobody has asked for. Decide these two out.

LZO is stable and nondeprecated in the IDL, but every available implementation is
GPL-2, so an MIT core cannot carry one. It was blocking the format-completeness
claim behind a license-compatible implementation that does not exist. INT96 is
deprecated in the format and was never implemented, although the roadmap claimed
both directions.

Neither makes a file invalid, so both now report an unsupported feature rather
than an invalid file, matching how every other recognized-but-unsupported feature
is reported. Record both as decided rather than pending, and state plainly that
complete coverage is not a goal.

The deprecated LZ4 codec and the deprecated BIT_PACKED encoding stay readable and
stay unwritable. Files in the wild use them, so dropping the read path would cost
compatibility rather than buy simplicity.

Re-freezing the N6 evidence follows from touching pinned package source. Verified
with the exact external gate on macOS 15 arm64: 5117 of 5117 preproduction checks
and 578 of 578 independent model checks.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
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.

4 participants