Skip to content

Split an oversized chunk across several Arrow record batches - #117754

Open
Avogar wants to merge 4 commits into
masterfrom
fix-arrow-big-string
Open

Split an oversized chunk across several Arrow record batches#117754
Avogar wants to merge 4 commits into
masterfrom
fix-arrow-big-string

Conversation

@Avogar

@Avogar Avogar commented Sep 2, 2026

Copy link
Copy Markdown
Member

Closes: #65723

Arrow addresses its Utf8, Binary and List buffers with 32-bit offsets, so a chunk
holding more than 2 GiB of string data cannot be written as a single record batch. Both Arrow
writers used one batch per chunk and failed on such a chunk:

SELECT randomString(40000) FROM numbers(60000) FORMAT ArrowStream
Arrow IPC string offset exceeds 32 bits. (TOO_LARGE_ARRAY_SIZE)

SELECT count() FROM arrowFlight('127.0.0.1:8890',
    '(SELECT randomString(40000) AS s FROM numbers(60000))')
Error with a Arrow column "String": Capacity error:
array cannot contain more than 2147483646 bytes, have 2147520000.

Both now split such a chunk into as many record batches as it needs. This keeps the schema on
32-bit offsets, so the output stays readable by every Arrow implementation — unlike switching
to LargeUtf8/LargeBinary, which the issue also asks for and which is better done
separately behind its own setting.

RecordBatchEncoder encodes a row range rather than a whole column, so a split batch reads
its rows out of the chunk instead of copying a slice of it. That cuts the peak memory of
writing a 2 GiB String column from 10.4 GB to 4.4 GB. As a side effect, a chunk's dictionary
messages are now emitted once for the chunk rather than once per batch.

The Apache Arrow library writer is shared by the Arrow Flight server, the ArrowFlight table
engine's insert path and DeltaLake writes, so all three get the fix.

Parquet and ORC were already unaffected — they no longer go through Arrow.

Changelog category (leave one):

  • Bug Fix (user-visible misbehavior in an official stable release)

Changelog entry (a user-readable short description of the changes that goes into CHANGELOG.md):

Fixed Arrow, ArrowStream and Arrow Flight output failing with Capacity error: array cannot contain more than 2147483646 bytes or Arrow IPC string offset exceeds 32 bits when a block held more than 2 GiB of String data. Such a block is now written as several Arrow record batches.

Arrow addresses its `Utf8`, `Binary` and `List` buffers with 32-bit
offsets, so a chunk holding more than 2 GiB of string data (or more than
2^31 list elements) cannot be written as a single record batch. Both
writers used one batch per chunk and failed on such a chunk:

    SELECT randomString(40000) FROM numbers(60000) FORMAT ArrowStream
    Arrow IPC string offset exceeds 32 bits. (TOO_LARGE_ARRAY_SIZE)

    SELECT count() FROM arrowFlight('127.0.0.1:8890',
        '(SELECT randomString(40000) AS s FROM numbers(60000))')
    Error with a Arrow column "String": Capacity error:
    array cannot contain more than 2147483646 bytes, have 2147520000.

Both now split such a chunk into as many batches as it needs, which is
transparent to readers and keeps the schema on 32-bit offsets, so the
output stays readable by every Arrow implementation.

`maxRowsFittingOneArrowBatch` computes how many rows fit one batch. It
walks the column structure rather than the data: a binary search over
`ColumnString` offsets, an offsets translation plus recursion for
`Array`/`Map`, `min` over `Tuple` elements, and nothing at all for the
fixed-width types. `LowCardinality` and `Variant` need a row scan, but
of integers only. Two top-level columns never share a buffer, so the
smallest per-column limit is the batch's limit.

The `String` encoder no longer assembles the data buffer in a temporary
when the column has no null map: the bytes of the range are contiguous
in `ColumnString::getChars()` and the ClickHouse offsets already are the
Arrow ones, so they go straight into the body. With a null map the
temporary is reserved up front instead of grown by doubling. This cuts
the peak memory of writing a 2 GiB String column from 10.4 GB to 6.3 GB.

Closes: #65723

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@clickhouse-gh

clickhouse-gh Bot commented Sep 2, 2026

Copy link
Copy Markdown
Contributor

Workflow [PR], commit [f1af830]

Summary:

job_name test_name status info comment
Finish Workflow FAIL
python3 ./ci/jobs/scripts/workflow_hooks/new_tests_check.py FAIL

AI Review

Summary

This PR teaches both Arrow writers to break an oversized ClickHouse chunk into multiple Arrow batches instead of failing once a String/Binary/List buffer crosses the 32-bit offset limit, and it fixes the follow-up range-slicing bugs found in review. The remaining concern is evidence rather than implementation: the Apache Arrow library path still has no committed regression that exercises a real split.

Missing context / blind spots
  • ⚠️ The linked Praktika report currently exposes only Build profile diff, so I could not confirm from CI whether the new stateless and integration tests ran on the current head.
Tests
  • ⚠️ tests/integration/test_arrowflight_interface/test.py still proves only that small ArrowFlight results stay single-chunk. The actual library-writer split path in CHColumnToArrowColumn::calculateArrowTable still has no committed regression that drives a non-zero top-level start/end range, so the main Bug Fix claim for ArrowFlight/Delta Lake rests on code inspection plus an uncommitted lowered-limit check. A focused unit test that forces a split with a smaller limit would close this gap.
Final Verdict

Implementation looks sound after the follow-up fixes, but I would still want one committed regression that forces the Apache Arrow writer down its real split path before calling the evidence complete.

LLVM Coverage Report

Measured on commit f1af830.

Metric Baseline Current Δ
Lines 88.70% 88.70% +0.00%
Functions 92.10% 92.10% +0.00%
Branches 81.00% 81.00% +0.00%

Changed lines: Changed C/C++ lines covered: 482/549 (87.80%) · Uncovered code

Full report · Diff report

@clickhouse-gh clickhouse-gh Bot added the pr-performance Pull request with some performance improvements label Sep 2, 2026
@Avogar Avogar added pr-bugfix Pull request with bugfix, not backported by default pr-must-backport Pull request should be backported intentionally. Use this label with great care! and removed pr-performance Pull request with some performance improvements labels Sep 2, 2026
Comment thread src/Processors/Formats/Impl/ArrowBatchRowLimit.cpp Outdated
Comment thread src/Processors/Formats/Impl/ArrowIPC/ArrowIPCBlockOutputFormat.cpp
@zlareb1

zlareb1 commented Sep 2, 2026

Copy link
Copy Markdown
Member

Read the head (b33b28ee) end to end against the encoders — the split arithmetic itself looks exact (strict upper_bound against base + limit agrees with the encoders throwing only above MAX_ARROW_BUFFER_SIZE, the last partial batch is always emitted, progress is at least one row per iteration, and the ArrowIPC path's cut rebuilds String/Array offsets and LC dictionaries per batch). Two gaps, both fail-loud:

1. LowCardinality(FixedString(n)) is exempted from the split but is still written through int32 offsets when output_format_arrow_fixed_string_as_fixed_byte_array = 0. maxRowsForLowCardinality returns num_rows for any non-String dictionary type (ArrowBatchRowLimit.cpp:47-48) and never receives fixed_string_as_fixed_byte_array, but the encoder materializes the LC column (RecordBatchEncoder.cpp:595-601) and the offsets-mode FixedString arm then emits int32 offsets totalling num_rows * n and throws throwValueDoesNotFitArrowBuffer past the cap (RecordBatchEncoder.cpp:432-448). So a perfectly splittable multi-row LowCardinality(FixedString(n)) batch over 2 GiB still throws instead of splitting — the same error class the PR fixes — and the new "a single value cannot be larger than…" message plus the "only reachable for a single oversized row" comment are both wrong for this shape. Plain FixedString handles exactly this at ArrowBatchRowLimit.cpp:163-169 (capped at MAX / n in offsets mode); only the LC wrapper bypasses it, because the isString screen exits before the mode is consulted. The library/flight path is immune (it strips LC before estimating). Suggest threading fixed_string_as_fixed_byte_array into maxRowsForLowCardinality and applying the plain-FixedString cap to the materialized width; a test with LowCardinality(FixedString) over the threshold under output_format_arrow_fixed_string_as_fixed_byte_array = 0 would pin it.

2. The ArrowIPC caller runs the estimator on un-normalized columns. ArrowBatchRowLimit.h documents that ColumnConst and the sparse/replicated representations are not handled and callers materialize first; the CHColumn caller complies, but ArrowIPCBlockOutputFormat::consume estimates straight off the chunk. Today that is safe only via the pipeline invariant (MaterializingTransform from expectMaterializedColumns), while encodeField itself keeps defensive const/replicated materialization for exactly this class of caller (RecordBatchEncoder.cpp:576-593). If any future caller feeds a const String/Array/Variant column, the estimator's assert_cast is a bad-cast exception in debug and an unchecked cast in release, where the encoder alone would have handled it. Cheap to align: materialize (or unwrap const) before estimating, mirroring the encoder.

The previous wording claimed the result is only ever conservative. That
is wrong when `LowCardinality` is written as an Arrow dictionary: the
emitted dictionary bytes do not depend on the row count, because
`ColumnLowCardinality::insertRangeFrom` keeps a shared source dictionary
whole, so every slice re-emits all of it. A chunk whose dictionary alone
exceeds one buffer is therefore not covered by splitting - as it was not
before splitting existed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Comment thread src/Processors/Formats/Impl/CHColumnToArrowColumn.cpp
`LowCardinality(FixedString(n))` was exempted from the row limit because
only a `String` dictionary can hold a variable-width value. But a writer
materializes the column, and with
`output_format_arrow_fixed_string_as_fixed_byte_array = 0` the resulting
`FixedString` goes through 32-bit offsets, so a multi-row batch over the
cap threw instead of splitting:

    SELECT toFixedString(toString(number % 3), 100000)
               ::LowCardinality(FixedString(100000))
    FROM numbers(21475)
    SETTINGS output_format_arrow_fixed_string_as_fixed_byte_array = 0
    FORMAT ArrowStream
    Cannot write a value of 2147500000 bytes to Arrow IPC ...

`maxRowsForLowCardinality` now receives the mode and applies the plain
`FixedString` cap to the materialized width.

`buildArrowListArrayWithArrayColumnData` narrowed the *absolute*
`ColumnArray` offsets to int32 before rebasing them against
`values_start`. That was equivalent while every range started at row 0,
but a split feeds it a non-zero start, so offsets `[1500000000,
2300000000]` rejected the second batch even though its rebased slice
`[0, 800000000]` fits one `List` buffer. The offsets stay 64-bit and the
limit is now checked on the rebased value, where it applies.

The error message and the comment above it claimed only a single
oversized row could reach the throw, which the `LowCardinality` shape
above disproves.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Comment thread tests/integration/test_arrowflight_interface/test.py
@clickhouse-gh

clickhouse-gh Bot commented Sep 2, 2026

Copy link
Copy Markdown
Contributor

Build profile diff (arm_release)

Comparing f1af830f0 with master 2400b5ade (stripped binary size, per-symbol sizes and ThinLTO time; object sizes against the warmup build of dfde23932; compile times per translation unit against the most recent warmup build that recompiled it).

✅ No significant changes.

Binary sizes

programs/clickhouse-stripped: smaller than the master baseline by the known offset between the two builds, so the difference is not shown. A delta that differs from the offset by more than 50% of it is shown, in either direction.

The official master build is compiled with -g and a pull request build is not, and XRay counts debug instructions towards its instrumentation threshold, so master instruments thousands of functions more and its binary is ~0.4% larger no matter what the pull request does.

Object file sizes

55 object files changed (-41.32 KiB total), 1 added.

Object file Master PR Δ
src/CMakeFiles/dbms.dir/Processors/Formats/Impl/ArrowBatchRowLimit.cpp.o new 21.80 KiB +21.80 KiB

716 more object files are built by the master warmup baseline only (it builds every object-file target, a pull request build only clickhouse-bundle) and not compared.

Compile time of recompiled translation units

148 translation units recompiled, 1477 s compile time in total, 147 of them have a recent master baseline.

Median compile-time ratio to the baselines is ×1.06 (machine-speed difference or a change affecting every TU); per-TU deltas below are relative to that ratio.
The matched translation units cost +80.7 s (+6%) in total before that adjustment.

Job report

Splitting an oversized chunk cut every column for each batch, because
`RecordBatchEncoder` could only encode a whole column from row 0. For a
2 GiB `String` column that held three copies of the data at once - the
chunk, the cut, and the body - and the stateless test hit the container
memory limit in CI:

    05055_arrow_split_large_record_batch.sh: line 18: Killed
        SELECT number, repeat('x', 100000) FROM numbers(21475)
        FORMAT ArrowStream

The encoder now takes `[begin, end)` throughout, so a batch reads its
rows straight out of the chunk. Peak memory for that test drops from
10.4 GB before any of this work, to 6.3 GB, to 4.4 GB.

Offsets have to be rebased against the row before the range: `String`
against `offs[begin - 1]`, `List`/`Map` against `ch_offsets[begin - 1]`
(and the range translated into the child's element range), and a dense
union's offsets against each alternative's own start, which comes from
the same per-discriminator scan `ColumnVariant::updateHashWithValueRange`
uses. Only `Const`, `ColumnReplicated` and `LowCardinality` still cut,
which is cheap: those cuts resize a constant or slice indexes without
copying values.

`consume` now substitutes dictionaries once per chunk rather than once
per batch, so a chunk's dictionary messages precede all of its record
batches instead of interleaving with them.

Verified by building with `MAX_ARROW_BUFFER_SIZE = 24` to force splits on
small data and round-tripping the whole type matrix, checking the batch
counts with pyarrow.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

comp-formats Input/output formats (CSV/JSON/Parquet/ORC/Arrow/Protobuf/etc.). pr-bugfix Pull request with bugfix, not backported by default pr-must-backport Pull request should be backported intentionally. Use this label with great care!

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Missing way of dealing with out of capacity in Arrow and ArrowStream formats

3 participants