Skip to content

Rewrite Arrow.jl internals for 3.0 - #609

Draft
quinnj wants to merge 377 commits into
mainfrom
core-rewrite
Draft

quinnj wants to merge 377 commits into
mainfrom
core-rewrite

Conversation

@quinnj

@quinnj quinnj commented Aug 20, 2026 •

Copy link
Copy Markdown
Member

Summary

  • preserve per-batch dictionary snapshots, accept undeclared stream replacements, and read dictionary deltas in streams, files, and range scans; resume Arrow.append from the final dictionary state even when an update follows the last record batch; fixes Arrow.Table uses last dictionary for all batches in streaming decode, resulting in incorrect reads #610
  • replace the Arrow 2.x internals with a validated core, new IPC reader and writer, C Data and C Stream support, and a materialized public table facade
  • compile each Tables.Scan once, keep storage and public-domain plans separate, preserve stable empty-result schemas, and support sparse byte-range reads with footer statistics
  • deepen column construction around one recursive adapter for native, retained-schema, dictionary, Union, and ArrowTypes-backed columns
  • retain automatic ArrowTypes custom serialization, public-domain lifting, extension metadata, and nested custom-type round trips
  • use registered DataStrings 1.0 as the StringView representation package and keep Arrow.jl's export surface small
  • use DataDecimals 1.0 and Durations 1.4 for supported top-level decimal, interval, and timestamp columns; every zone-naive timestamp reads as Durations.Timestamp{P} at the column unit (a written DateTime column reads back as an instant-equal Timestamp{Millisecond}) and every timezone-declared timestamp as Durations.ZonedTimestamp{P,Z} (no TimeZones.jl requirement), both writing back zero-conversion with every comparison operator pushing down; preserve wire descriptors on rewrite
  • make the public value domain uniform at every depth: temporal, decimal, and interval leaves materialize as the same public values inside struct, list, map, and union rows and under run-end-encoded and dictionary wrappers as at the top level; the writer accepts them back at every depth (fresh and retained), scan literal lowering is transparent through run-end encoding, and the reader allocation budget covers the dynamic conversion work at every seam (docs/dev/DESIGN-nested-public-values.md is the design contract)
  • discard inherited batch statistics when rewriting edited tables so filters cannot skip changed rows
  • reject invalid writer options before opening destination files, release owned files after failures, and preserve the original error
  • save only referenced Utf8View/BinaryView bytes, retain shared ranges, and avoid sorting and per-entry copies when referenced bytes already cover each copied buffer prefix
  • remove JSON3 and StructTypes integration and support claims
  • deepen deterministic fuzzing, Apache Arrow corpus coverage, PyArrow/nanoarrow oracles, C Data checks, allocation limits, trim checks, documentation, and package administration

Current validation

Latest head: 0ffdf1436664228629cbe619249011a415d216f7.

Appending to a valid IPC stream with a trailing dictionary replacement or delta previously either wrote incorrect values or rejected a matching dictionary. The reader now retains the final dictionary map; append resumes that map while earlier record batches keep their original snapshots. Public file and IO regressions cover replacements, deltas, and uncompressed/LZ4/Zstd streams.

  • Full package suites pass on Linux Julia 1.10.12 and 1.13.0 with four threads and bounds checks, including CloudStore integration, all four acceptance batteries, and Aqua.
  • All 624 focused dictionary and incremental-writer assertions pass on Julia 1.10.12 and 1.13.1. These include 90 assertions covering trailing updates and refusal of a mismatching pool without altering the destination.
  • Six independently generated PyArrow 25.0.1 streams reproduce the append defect on the prior head; the candidate passes all 30 public reproduction controls. PyArrow full validation confirms exact values and row counts in all six output streams after two public appends each.
  • Pinned Apache Arrow corpus: 388 pass, 0 fail, 63 declared skips. PyArrow 25.0.1/nanoarrow 0.9.0 oracle: 315 pass, 0 fail, 20 declared skips. C Data/C Stream oracle: 147 pass, 0 fail, 8 declared skips. These ran on Linux Julia 1.12.6.
  • Strict Documenter 1.19.0 and the exact CI JuliaFormatter 2.12.4 check pass.
  • An independent private review covered the complete runtime/test diff and coverage workflow changes.

All 55 hosted checks pass at 0ffdf1436664228629cbe619249011a415d216f7: both CI runs and the conformance run completed successfully. All 34 native coverage uploads were accepted for apache/arrow-julia at that exact commit, and all 34 reports finished processing, with aggregate coverage at 91.93%. Prior green job logs show coverage uploads were rejected with Token required because branch is protected. Uploading jobs now use scoped OIDC for repository-owned runs and fail on upload errors; public forks retain tokenless upload support. Coverage percentage thresholds and processing paths are unchanged. The notification count now matches one 17-report CI run, so main commits and branches without a duplicate PR run can report coverage; all 34 uploads from this PR's push and PR runs were independently verified. The scheduled nightly upload path receives the same authentication repair; scheduled execution is not part of the local validation above.

Dependency

JuliaData/Tables.jl#380 is merged and released as Tables.jl 1.14.0. Commit dd8c272 removes the temporary source and CI pins, raises the Tables.jl compatibility floor to 1.14, and resolves Tables from the General registry everywhere (package, CI workflows, conformance image, docs).

Breaking changes

This is the Arrow.jl 3.0 rewrite. Important changes include:

  • Julia 1.10 is now required.
  • Arrow.Table and Arrow.Stream return materialized Julia vectors instead of lazy Arrow vector wrappers.
  • Arrow.write is eager and whole-buffer. Arrow.Writer and IPC-stream append are reimplemented with fixed-schema semantics; the curried write and tobuffer compatibility forms remain. Obsolete writer tuning keywords are ignored with warnings.
  • Arrow.write(io, table) writes file format by default; use file=false for stream format.
  • ArrowTypes-based custom serialization remains automatic. ArrowTypes stays exported only as an Arrow 2.x compatibility exception. Packages that define mappings should depend on and import ArrowTypes directly. Arrow.ArrowTypes remains as a qualified compatibility binding.
  • most APIs are no longer exported and must be called through Arrow.

Fresh declared heterogeneous Julia Union columns are written as canonical dense Arrow Unions. A retained Union rewrite fails closed when materialization has lost its original route. Nested retained dictionary pools follow the same fail-closed rule.

See CHANGELOG.md and docs/src/migration.md for the complete list and migration guidance.

Release preparation

  • DataStrings, DataDecimals, and Durations 1.4 (Timestamp/ZonedTimestamp) resolve from General; the bundled ArrowStrings package and its release steps are removed
  • the Apache release-candidate template and CI now enforce a vote window of at least 72 hours
  • Arrow.jl remains at 3.0.0-DEV pending the rewrite merge and Apache release process

Co-authored by Codex

AI disclosure: This work was prepared with assistance from OpenAI Codex.

🤖 Generated with Claude Code

quinnj and others added 30 commits August 13, 2026 17:00
Include raw field metadata and dictionary IDs in the semantic equality check between the leading file schema and Footer.schema.

Co-Authored-By: Codex <codex@openai.com>
Validate C format descriptors before recursive or geometry work. Export and require the canonical terminal offset for empty offset layouts.

Co-Authored-By: Codex <codex@openai.com>
Keep only a slice across the GC reachability probe, then collect the mapping before deleting its path on platforms that lock active mappings.

Co-Authored-By: Codex <codex@openai.com>
Track each result export key until its caller-owned C struct is published. Discard unpublished roots and keep failed batches available for retry.

Co-Authored-By: Codex <codex@openai.com>
Co-Authored-By: Codex <codex@openai.com>
Use verified block extents before classifying the final marker-shaped bytes, so valid no-EOS files cannot lose part of their last data buffer.

Co-Authored-By: Codex <codex@openai.com>
Co-Authored-By: Codex <codex@openai.com>
Co-Authored-By: Codex <codex@openai.com>
Verify each Footer Block against its on-wire Message envelope before
classifying optional EOS bytes. Keep record decoding and resource limits
lazy, and cover forged collision extents plus zero-body Blocks.

Co-Authored-By: Codex <codex@openai.com>
Bind each Footer Block to its Message header, nested RecordBatch, and
declared wire-buffer extents before optional EOS detection. This rejects a
coordinated Footer and Message length forgery while keeping lazy decoding
and configured resource limits unchanged.

Co-Authored-By: Codex <codex@openai.com>
Record the optional-EOS Block-verification finding, its completed
disposition, the checked-clean areas, and final validation. Align the README
review index and file-verification claim.

Co-Authored-By: Codex <codex@openai.com>
Pass readfile's existing whole-file slice into the Block preflight. This removes the stable 48-byte allocation from constructing an identical second slice after warm-up.

Co-Authored-By: Codex <codex@openai.com>
Record the Block-preflight allocation finding, its narrow fix, the checked-clean binding and interoperability results, and final validation.

Co-Authored-By: Codex <codex@openai.com>
Closing verification of the round-16 slice-reuse fix: zero findings,
VERDICT: CLEAN. Rounds 14-17 converge the adapter build-out (20 -> 1 -> 1
LOW -> 0).

Co-Authored-By: Codex <codex@openai.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…iers

Proposal extending report §9/§14: Stage-A/B Scan apply semantics (IO
reduction with full residual now; exact facade consumption later),
limit/offset as decode-free exact pushdowns, the RangedSource fetcher
contract + coalescing range planner over the footer/Block/buffer-table
metadata, footer-carried per-batch statistics for tail-fetch-only
pruning, and the two-tier trimmable public API where Scan type overrides
double as the known-schema pin.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
skipfield!/skipbuffer! advance the registry cursor with every
buffer-table invariant intact but zero body access; Tables.apply on
ArrowFile decodes only (selected ∪ filter) columns, consumes
limit/offset exactly via metadata row counts (whole batches skipped),
and returns source-named columns with a RESOLVED residual selection —
Not/Regex re-binding against the reduced table is wrong, which the
differential battery caught. Acceptance: 15 scans differentially equal
to Tables.finish over the full table, corruption probes proving skipped
columns and window-excluded batches are never decoded, residual
consumption semantics, and the duplicate-name facade boundary. Requires
Tables.jl#jq/scan dev'ed into the repo project (Manifest is untracked).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
RangedSource{F} is the design's fetcher contract (parametric functor,
fetchranges overridable for concurrent transports); RangedFile.apply runs
the fetch protocol: tail-first footer, one coalesced metadata pass,
dictionary bodies only for decode-set ids, per-buffer body ranges for
exactly the surviving batches' decode set, and sparse decode through a
SparseBody standing in for the contiguous message body (DecodeCursor{B}
parametrizes the seam; _bodyslice is the one indirection). Counting-source
acceptance proves the model: differential equality with whole-file reads,
14% of bytes for a narrow column over a 2.3MB file, corruption-backed
never-fetched proofs for skipped columns, window-excluded batches, and
unneeded dictionary bodies, request/byte coalescing trade-offs, undersized
tails, compressed files, and forged-footer refusals. Footer-only schema
authority (no leading-message cross-check, blocks bounded by footer start)
is documented loudly as the ranged reader's trust divergence.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…runing

withstatistics folds per-column (null count, min, max) plus row_count per
batch into the statistics schema's official array layout — struct<column,
map<dictionary<utf8,int32>, dense_union<i64,f64,utf8,bool>>> — serialized
through this writer itself and carried base64-wrapped under
JuliaArrow:batch_statistics.v1 in schema metadata (placement is our
convention; the spec declares placement a non-goal). Both applies prune
with one-sided may-contain interval logic (Cmp/In/IsNull/startswith with
bytewise prefix successors, And/Or, Not-of-equality); the ranged path
prunes before its block-metadata pass so pruned batches cost zero
fetches; the filter always stays in the residual. Acceptance pins
differential exactness under pruning on both paths, never-fetched and
never-decoded proofs, malformed-blob degradation to no pruning, and the
trust model in both directions: wide lies only cost pruning, narrow lies
silently lose rows (trusted-for-completeness, as Parquet).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Resolve positional filter references against source names before the residual is rebound. Validate batch row metadata before it drives a window, and preserve row counts for zero-column results.

Add differential and corruption regressions for both whole-file and ranged applies.

Co-Authored-By: Codex <codex@openai.com>
Enforce Footer Block overlap, features, limits, and complete RecordBatch metadata before any body range is fetched. Make coalescing overflow-safe and validate overridden multi-range fetch results.

Use one allocation budget and codec state across each whole-file Scan apply. Correct the design request model and add fail-closed, fetch, and aggregate-budget regressions.

Co-Authored-By: Codex <codex@openai.com>
Co-Authored-By: Codex <codex@openai.com>
Co-Authored-By: Codex <codex@openai.com>
Co-Authored-By: Codex <codex@openai.com>
Co-Authored-By: Codex <codex@openai.com>
Co-Authored-By: Codex <codex@openai.com>
Reject legacy compression, unsupported codecs, and missing dictionary
ids while only metadata is loaded. Apply per-record limits after
statistics pruning to match ArrowFile lazy acceptance.

Co-Authored-By: Codex <codex@openai.com>
Describe surviving-record validation and distinguish planned ranges from
tail and coalescing over-read.

Co-Authored-By: Codex <codex@openai.com>
Co-Authored-By: Codex <codex@openai.com>
Co-Authored-By: Codex <codex@openai.com>
Co-Authored-By: Codex <codex@openai.com>
quinnj and others added 30 commits October 22, 2026 05:09
A NamedTuple column whose rows have different field types, such as rows
with some fields missing, has an abstract element type like
NamedTuple{(:a, :b)}. Arrow 2.x wrote it as one Struct with
Union{Missing, T} children; Arrow 3.0 refused it. Narrow such a
column to the field-wise Union of its observed row types before
construction.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Arrow 2.x accepted a vector of sources; on 3.0 `Arrow.Table([a, b])` or
a byte view failed with a MethodError naming the internal _opensource.
Throw an ArgumentError that lists the supported source kinds and says
to read several sources separately.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Arrow 2.x wrote every column with a DataAPI ref pool (PooledArray,
CategoricalArray) dictionary-encoded. Arrow 3.0 wrote PooledArrays as
plain values and failed on CategoricalArrays with "fromjulia: unsupported
element type Ptr{Nothing}", because the default struct reflection walked
into CategoricalPool internals; CategoricalArrays' Arrow extension cannot
load on 3.0. Write a fresh ref-pool column dictionary-encoded with its
pool order and unused entries, unwrapping values through DataAPI.unwrap
when their type has no ArrowTypes.jl mapping, so a mapping that a future
CategoricalArrays extension defines still applies. When struct reflection
does fail, name the column and element type and say how to convert it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Arrow 3.0 defines DataAPI.metadatasupport and colmetadatasupport, which
DataAPI added in 1.12. The compat bound allowed DataAPI 1.0-1.11, with
which Arrow fails to precompile (UndefVarError: metadatasupport).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Arrow.jl releases before 2.6 wrote the file footer with metadata version
V4 over V5 messages, so Arrow 3.0 refused every file-format output of
Arrow.jl 1.0 through 2.5 ("file schema and footer metadata versions
differ"); MixedModelsDatasets' cbpp dataset is one. pyarrow reads them.
Accept a V4 or V5 footer over V4 or V5 messages; the message version
still selects the layout.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Cover the list, map, and fixed-size-list row types and their conversions,
DateTime conversion of timestamp columns, the rejection of bytes after a
stream's end-of-stream marker (Arrow 2.x read only the first stream), and
replacements for the Arrow.names and Arrow.metadata accessors that
downstream packages call.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Every table after the first that Arrow.Writer writes must reuse the first
table's exact dictionary pool. With automatic ref-pool encoding, two
tables whose PooledArrays had different pools failed the second write
("dictionary id 0 changed pools"), where they previously wrote as plain
values. Arrow.Writer and later-table construction now write ref-pool
columns as their values, unwrapped when needed; explicit DictEncode
columns are still encoded. The eager writer keeps encoding them.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Every DataAPI column-metadata call rechecked that the retained schema
aligned with the output columns by interning each field name with
Symbol(name). DataFrame(table) lists all columns' metadata, so it was
quadratic in the column count: 0.6-0.9 s for a 2000-column table (Arrow
2.x: under 1 ms), 1.9 s to list a 3000-column table's metadata. Check
alignment once per listing, compare names without interning them, and use
the name lookup to detect duplicate names.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Julia 1.10 boxes in the per-element null punch (73 MB per 1M nullable
Float64 values after the copy removal, 90 MB before), so the 14 MB bound
holds only on newer Julia; the Int64 pin holds on 1.10 too.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A union array has no validity bitmap, so its physical null count is
always 0 even when a selected child slot is null. _statfold recorded
that 0, and _maypass then pruned every batch under isnull(col(:u)),
silently dropping the matching rows from files written with
withstatistics/statsfile. Derive the logical count the same way the
dictionary and REE layouts already do.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A scan that selects one column twice (`select=(:u, :u => :v)`) gives
both output fields the same retained child Field objects. Column
construction for ArrowTypes extension columns with children (UUID,
Vector{<:extension}, structs or dictionaries holding one) reused those
child objects, so Arrow.write of the scan result failed with "IPC writer
schema reuses one Field object in multiple positions". Copy each
retained field tree before constructing the column; the writer never
keys anything on the retained objects themselves.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Arrow keeps the vectors readrange returns without copying them: the tail
window is cached on the handle and fetched spans are decoded after later
requests. A source that reuses one buffer for every request silently
corrupts results (or leaves Arrow holding a stale pointer after a
resize!). Say so in the AbstractArrowSource and readrange contracts.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A full read retains each dictionary column's source category pool, but
every scan result (even an identity Scan()) dropped them. Arrow.write
then refused any scanned ordered dictionary ("its original category
pool is unavailable") and rebuilt unordered pools from the surviving
values, losing category order and unused categories.

File and ranged scans now remember the first decoded dictionary of each
selected dictionary column (file batches share each dictionary's final
pool), stream scans merge their batch snapshots as a full read does, and
the public fallback takes the full table's pools. A real type override
drops the pool exactly when it drops the retained field.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The battery's remote-scan budget sat 536 bytes above what the opened read
needed. Reading without the redundant public-column copy lowered both
needs by the same amount (opened 739,272, remote 797,366 bytes on Julia
1.10 and 1.12), so pick a limit between them again.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The eager writer copied each buffer into a fresh vector before appending
it to the message body, grew the body, the batch message, and the output
buffer by repeated appends, and so allocated about 6x the data it wrote.
Copy uncompressed buffers straight from their slices, size the body and
the message once, and size the eager output buffer from the record
bodies. Writing 2x2M Int64/Float64 to an IOBuffer allocates 132 MiB
instead of 188 MiB, and to a path 92 MiB instead of 147 MiB.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A forged Footer Block offset or body length close to typemax(Int64) made
the block-extent check's checked addition throw a raw OverflowError on
every read path (found by the structure-aware fuzzer: 398 of 1,500 footer
mutants). Compare the nonnegative extents against the data section end
without overflowing.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A declared timezone becomes the Z parameter of ZonedTimestamp{P,Z}. A zone
string containing NUL escaped as Base's raw ArgumentError (found by the
fuzzer on the ranged path, which reads only the footer schema), and a zone
string of any length was interned as process-global Symbol state. Refuse
both as ValidationError, with the facade's 4096-byte per-name bound.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The pin measured the first call, so it counted compilation: Julia
1.14.0-alpha1 (the CI "pre" channel) allocates 1.6 MB compiling it there
and 42 MB cold, versus 14 KB warm. The pin is about per-row work, so
measure a warmed call.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Julia 1.14 hashes a Date like the midnight DateTime it isequal to, so
Date(2024, 3, 1) in Set([DateTime(2024, 3, 1)]) is now true (false through
1.12). The battery hard-coded the old answer; Arrow's scan already agreed
with Tables.scan on both. Take the expected row from Julia's in.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…cation tests

The "materialized once" pins measured `@allocated`, which counts the usable
size of each big malloc block. macOS malloc can satisfy a 9 MB request with
a larger cached block (12-17 MB observed), so the nullable-read pin failed
intermittently on macOS CI with no extra allocation: same allocation counts,
and the reported excess equals the block's `malloc_size` minus the request.

Measure the bytes requested, via Profile.Allocs at sample rate 1, which is
deterministic on every platform and still fails if a second copy of the
column appears.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The dictionary encoder scanned categories with `for v in vals, x in v`
over a Vector{AbstractVector} and converted indices through a run-time
index type, so each row paid four heap allocations (an iterate tuple, two
Int64 boxes, and a storage key). Writing a 1M-row PooledArray or
DictEncode string column took 300-450 ms; Arrow 2.x took about 85 ms.

Put both loops behind function barriers. The same column now writes in
about 40 ms with ~81k allocations (from 4.08M). Ref-pool columns, which
write dictionary-encoded as in Arrow 2.x, take this path, so a typical
DataFrame with a PooledArray column no longer writes slower than 2.x.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Null counting (run by semantic validation on every nullable read and
write) and the nullable bulk read's null punch called isvalid_at per
element, which repeats the buffer-role lookup and a bounds-checked load
for every bit. Check the bitmap bytes' bounds once and walk them
directly; out-of-range bitmaps still throw BoundsError as before. A new
core test compares both against per-element validity for every slice
offset and length over 40 slots.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Under a typed claim, fixed-width values (integers, floats, temporal,
interval, bool, null) charge nothing beyond the output column, yet the
budgeted materialize walked every value to learn that. Skip the walk for
those layouts. Also drop the reservation for a Vector{E} scratch copy:
the nullable bulk path loads values straight into its result and has no
scratch, so default limits refused nullable columns smaller than they
can read.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The builder made a Vector{Bool} of presence flags, scanned it twice
(any, count), then built the bitmap and the values in two more passes.
Fill values and bitmap in one loop.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Writing a Vector{Union{Missing,Int64}} (or any Union of concrete
non-container types) walked every value through an IdDict lookup to ask
whether ArrowTypes lowering was needed, although the type-level check
already answers for every value such a column can hold. Skip the walk
for those element types, as concrete element types already did.

2x10M nullable Int64/Float64 write (Julia 1.12, best of 7 after GC):
215 ms at the PR head -> 67 ms with this and the previous two commits
(Arrow 2.8.1: 40 ms). Nullable read of the same file: 253 -> 35 ms.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
CloudBase 2.0 moved its CloudTest emulators to the test-only
CloudBaseTest package, so `using CloudBase.CloudTest` fails as soon as
the test environment resolves CloudBase 2 (every CI job since its
release). CloudBaseTest 1 supports CloudBase 1.6 through 2.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…hildren

The fixed-list and Struct allocation children compare one `@allocated`
reading of a small and a large write. `@allocated` counts each big
block's usable size, and macOS malloc can satisfy a request with a larger
cached block, so a single reading of a 20-30 MB workload can come in
several MB high: generalhidden(50_000) requests 28.0 MB and read 32.3 and
36.1 MB in two runs, against 3.5 MB of slack in its bound. The fixed-list
child failed 2 of 19 checks on two Julia pre macOS jobs this way, with
allocation numbers identical to the last passing commit.

Measure each side as the least of three readings after a full
collection. The bounds are unchanged; a real extra allocation still shows
in every reading.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Every read was charged against one flat 256 MiB budget, so default
Arrow.Table refused ordinary files: past 16.7M Int64 rows (a 128 MiB
file), 1M 64-byte strings, 14.9M nullable Float64. Arrow 2.x read a
610 MB file with no options.

A read's budget is now `max_allocation_ratio` (new, default 64) bytes
per input byte, at least 256 MiB and at most `max_total_allocated_bytes`
(default now 16 GiB). Ordinary columns cost a small multiple of their
stored bytes, so they read with the defaults. Inputs under 4 MiB keep
exactly the old 256 MiB allowance, so lengths a small malformed file
declares without bytes behind them (run-end encoded, view, null-child,
decompressed) still fail before allocation: a 464-byte file whose REE
column claims 1e9 rows fails in 0.1 s after allocating 10 MiB. Raising
only the ceiling would have let a 464-byte file claiming 5e7 rows
allocate 382 MiB.

The bound applies where the input size is known: byte, IO, and unmapped
path reads, mapped files, ranged object reads (the object length), and
handle-level batch and scan reads. Two tests that assumed the budget
equals `max_total_allocated_bytes` now read the actual allowance, the
empty-container guard keeps its explicit 256 MiB, and the error names
both knobs.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
`Arrow.Table(handle)` and `Arrow.Stream(handle)` gave an `ArrowFile`
handle a fresh budget at `max_total_allocated_bytes`, and handles never
pass through the input-sized bound added in ed0ed67: the 464-byte
run-end encoded file claiming 1e9 rows, opened with `Arrow.readfile`,
allocated 7.6 GB over 69 s instead of failing. Size that budget from the
handle's region. The regression test now reads the file through bytes,
IO, and stream and file handles.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
f263a5a moved every ARROW:extension pair of a dictionary Field to the C
Data value node, as the C++ bridge does for an extension type it knows.
For a name PyArrow does not know, such as arrow-testing's
"dict-extension", PyArrow then dropped the pairs: the conformance job's
C Data oracle lost `dict_exts`' metadata in all four round trips (143
pass, 4 fail; 147 and 0 before f263a5a). Move the pairs to the value node
only for canonical extensions, whose `arrow.` prefix the format reserves;
other names stay with the field metadata on the wrapper, where a
consumer that does not know them keeps them. PyArrow dictionary columns
of arrow.uuid and arrow.json, the case f263a5a fixed, still export back.

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

This branch has not been deployed

No deployments
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.

Arrow.Table uses last dictionary for all batches in streaming decode, resulting in incorrect reads

3 participants