fix(parquet): align DataPageV2 pages to row boundaries in spaced writes#883
Conversation
The spaced and dictionary-index write paths batched by a fixed WriteBatchSize using the free doBatches, while WriteBatch used the rep-level-aware columnWriter.doBatches. Since a page flush is only checked at a batch boundary, a repeated column written through WriteBatchSpaced or WriteDictIndices under DataPageV2 could end a page mid-row, so the next page began at repetition level != 0. That violates the DataPageV2 requirement that pages start on a row boundary and breaks offset-index page pruning. Route the non-byteArray spaced, boolean bitmap spaced and dict-index paths through w.doBatches, which falls back to identical behavior for V1, nil rep levels or MaxRepetitionLevel == 0. The byteArray and fixedLenByteArray spaced paths keep their adaptive size scan and gain the same rep_level == 0 shrink WriteBatch already applies.
|
Thanks for working on this. I tested the PR locally against the minimized I tested PR head replace github.com/apache/arrow-go/v18 => /tmp/arrow-go-pr883I do not think this fully fixes the issue yet. I see two remaining failure modes. 1. Variable-width / byte-array-like nullable list elements still write invalid DataPageV2 pagesThese cases still produce a Writer settings: Types I tested that still fail:
For So page 2 is still not row-aligned. It begins by continuing row 0's list, then reaches row 1. DuckDB can read the generated file successfully: I also tested a less tiny long-list variant to make sure this is not only a go run . -out /tmp/repro-pr883-string-long.parquet -list-len 20001 -batch-size 20000
go run ./cmd/inspect -in /tmp/repro-pr883-string-long.parquetThat still produces a non-row-aligned V2 page: 2. Fixed-width nullable list elements appear to hang / make no progressFor the same shape: the writer did not return within 2 seconds for:
This looks like a no-progress loop in the fixed-width Tests I think are worth adding to this PRI think the PR should cover both "still writes invalid pages" and "hangs / no progress". Suggested small tests:
One important debugging detail: the trigger is not merely that the list element type is nullable/optional. The data needs an actual null element. With no null values, |
…rites The initial apache#882 fix aligned spaced/dict DataPageV2 writes to row boundaries by shrinking each batch back to the previous rep_level == 0. That is incomplete when a single repeated row is wider than the write batch size: there is no row boundary at or before the requested split to shrink back to. For fixed-width elements the batch shrank to zero and doBatches looped without consuming any values (a hang). For byte-array elements the manual loop clamped the batch at one and still emitted a DataPageV2 that began mid-row. Both the columnWriter.doBatches method and the ByteArray/FixedLenByteArray spaced manual loops now grow the batch forward to the next row boundary when a shrink cannot reach one, keeping the whole (oversized) row in a single batch and page. Regression tests cover nullable list elements (string, binary, fixed_size_binary, float64) whose rows are wider than the batch size, plus a single list longer than the batch size. The write runs under a timeout so a regression to the no-progress loop fails instead of hanging.
|
Thanks for the thorough repro, @punkeel — you nailed the root cause. Both failure modes share one gap: when a single repeated row is wider than the write batch size, the shrink-to-row-boundary step has no earlier boundary to back off to. Fixed in d421e98.
Tests in
Analysis and fix by an autonomous AI agent; traced against source, verified red-green, and I can own follow-ups. |
|
Can the for loops computing batch size and co be simplified further? Is there already a helper for that in the codebase? Or in the Java parquet library, maybe? |
The three copies of the shrink-then-grow batch alignment (the doBatches method plus the ByteArray/FixedLenByteArray manual loops in the generated writer) are now a single alignBatchToRowBoundary helper. This also drops a latent inconsistency in the generated loops, which restarted the forward grow from WriteBatchSize rather than the value-scanned batch and could overshoot a nearer boundary.
|
Good call - the three copies (the Collapsing them also fixed a small inconsistency: the generated loops restarted the forward grow from I didn't lift anything from parquet-mr - Java doesn't split a write into batches like this. Suites still green with |
|
Code LGTM, with zero prior experience in this codebase. My local tests/reproductions seem to be working fine now. @truffle-dev please write a few more unit tests for |
Direct table-driven coverage for the row-boundary alignment helper: already-on-boundary, shrink to the previous boundary, grow forward when a single row is wider than the batch (including growing to the end of the slice), the tail cases where offset+batch is at or past the end, a non-zero offset, and the 0 / 1 / large batch edges. Each case also asserts the aligned batch ends on a row boundary.
|
Added Confirmed load-bearing: neuter the helper to |
| panic("columnwriter: not enough repetition levels for batch to write") | ||
| } | ||
|
|
||
| if repLevels[0] != 0 { |
There was a problem hiding this comment.
I'm surprised this didn't catch the bug. @truffle-dev is this assertion in the wrong spot? do we need this assertion in more places?
There was a problem hiding this comment.
Not the wrong spot, just narrower than it looks. It's a precondition on the batch handed to doBatches, and WriteBatchSpaced always enters on a fresh row, so repLevels[0] is always 0 here — it can't trip on this bug. The mid-row split happened a layer down: commitWriteAndCheckPageLimit flushes once buffered size crosses DataPageSize, and pre-fix the batches weren't row-aligned, so a page could close mid-row. This entry check never sees the per-page split.
The fix makes that invariant constructive rather than asserted — alignBatchToRowBoundary hands every action() a batch that begins and ends on a boundary, so each flush lands on one by construction. No broader runtime assertion is needed for correctness.
If you'd want defense-in-depth against a future regression in the helper, the spot is FlushCurrentPage just before buildDataPageV2: assert the flushed page's first repetition level is 0 and numBufferedRows matches the row-start count. That guards the boundary where the split actually happens instead of relying on the row-boundary tests. Glad to add it if you'd like it in the tree.
zeroshade
left a comment
There was a problem hiding this comment.
Thanks for this — the core DataPageV2 fix is sound, and I verified it locally: go generate ./parquet/file/... reproduces the committed .gen.go exactly, the new regression tests are genuinely red-green (neutering alignBatchToRowBoundary to return batch makes TestWriteBatchSpacedV2RowBoundary and all four TestWriteSpacedV2NullableListRowBoundary subtests fail with "starts mid-row"), and ./parquet/file/... + ./parquet/pqarrow/... pass. Nice catch that the fix also repairs per-page num_rows and the OffsetIndex first_row_index, not just the first repetition level.
Requesting changes for a few items before merge — mostly hardening and coverage for paths this PR touches, plus a decision on a closely-related V1 case. Specifics are inline; summary here.
1. Correctness hardening in columnWriter.doBatches
totalvslen(repLevels): the V2 branch drives its loop offlen(repLevels)and usestotalonly in the guard. If a caller ever passes arepLevelsslice longer thantotal, it writes more levels than requested. SuggestrepLevels = repLevels[:total]after the existing< totalcheck (or require exact equality).total == 0with a non-nil emptyrepLevelsindexesrepLevels[0]and panics (recovered as an opaque error). An earlyif total == 0 { return }is cleaner.- Zero-length tail: after a grow-to-end on an oversized row, the trailing
action(batchStart, len-batchStart)can fire with a 0-length batch; guard withif batchStart < int64(len(repLevels)).
2. Test coverage for modified-but-untested paths
WriteDictIndicesand the booleanWriteBitmapBatchSpacedWithErrorare both rerouted throughw.doBatcheshere, but no test exercises them under V2 + a repeated column (all three new tests setWithDictionaryDefault(false); none use bool). Please add coverage.- The boundary tests only decode repetition levels — none compare read-back values to the input. Since this change makes batch sizes variable (a value-cursor hazard), please add a value round-trip assertion for at least one repeated case. I ran one for
list<list<int32>>(maxRep=2) and it round-trips correctly, so this is about locking it in.
3. Related spec gap: V1 + OffsetIndex (please decide)
The spec requires row-aligned pages for V1 too when an OffsetIndex is present ("If an OffsetIndex is present, a page must begin at a row boundary"; and "Page indexes require pages to start and end at row boundaries, regardless of which page header is used"). doBatches falls back to the unaligned free doBatches for all V1, regardless of PageIndexEnabled. I confirmed empirically: a repeated column written as V1 + WithPageIndexEnabled(true) + small pages produced 12 of 16 data pages beginning mid-row — i.e. an invalid OffsetIndex. This predates this PR and affects WriteBatch too, so it may belong in a separate issue — but since this PR is about exactly this invariant, it would be ideal to either handle it here (align when DataPageVersion() == V2 || PageIndexEnabledFor(col)) or file a linked follow-up.
Non-blocking nits (inline)
- Oversized single row can grow past the 1GB byte-array cap (unavoidable under V2, but an early explicit error beats a late large-allocation failure).
- The wide-row test's assertions are effectively decorative (it passes even with the fix reverted); the real guard is the timeout, and the helper leaks a spinning goroutine on a hang regression.
The helper extraction is clean and the template/generated pair stays in sync — thanks again.
| for ; repLevels[batchStart+batch] != 0; batch-- { | ||
| } | ||
| // batchStart <--> batch now begins and ends on a row boundary! | ||
| batch = alignBatchToRowBoundary(repLevels, batchStart, batchSize) |
There was a problem hiding this comment.
Three hardening items around this loop:
totalvslen(repLevels)— this branch iterates onlen(repLevels)and only usestotalin the guard above. If a caller passes arepLevelsslice longer thantotal, it will write more levels than requested. ConsiderrepLevels = repLevels[:total]right after thelen(repLevels) < totalcheck (or require exact equality).total == 0with a non-nil but emptyrepLevelshits the earlierrepLevels[0]guard and panics (recovered as an opaque error). An earlyif total == 0 { return }avoids it.- Zero-length tail — when a single oversized row makes
alignBatchToRowBoundarygrow to the end, the trailingaction(batchStart, int64(len(repLevels))-batchStart)can fire with a 0-length batch. Guard withif batchStart < int64(len(repLevels)).
| dictEncoder := w.currentEncoder.(encoding.DictEncoder) | ||
|
|
||
| doBatches(int64(length), w.props.WriteBatchSize(), func(offset, batch int64) { | ||
| w.doBatches(int64(length), repLevels, func(offset, batch int64) { |
There was a problem hiding this comment.
WriteDictIndices is now correctly routed through the rep-aware w.doBatches, but I don't see a test exercising this path under DataPageV2 with a repeated column — all three new tests set WithDictionaryDefault(false), so the dictionary path is never hit. Could we add one?
Note from testing: dictionary columns rarely produce more than one data page (page sizing uses DictEncoder.ObservedRawSize()), so forcing a multi-page split likely needs many distinct values and/or a high WithDictionaryPageSizeLimit.
| } | ||
|
|
||
| doBatches(int64(length), w.props.WriteBatchSize(), func(offset, batch int64) { | ||
| w.doBatches(int64(length), repLevels, func(offset, batch int64) { |
There was a problem hiding this comment.
Same here — the boolean bitmap spaced path is rerouted through w.doBatches, but there's no DataPageV2 + repeated boolean test covering it. Worth a small regression test.
| // so snap the batch onto the nearest row boundary (or keep a single wide row | ||
| // whole). See alignBatchToRowBoundary in column_writer.go. | ||
| if isV2WithRep { | ||
| batch = alignBatchToRowBoundary(repLevels, levelOffset, batch) |
There was a problem hiding this comment.
Non-blocking: after the 1GB maxSafeBatchDataSize scan above, alignBatchToRowBoundary can grow the batch forward past that cap when a single row is wider than it. That's unavoidable under V2 (a row can't span pages), and FlushCurrentPage does guard uncompressed > MaxInt32 — but the failure comes late (after encoding a huge buffer), and per-value BYTE_ARRAY lengths are cast to uint32. Consider an early, explicit error when an aligned single row can't fit Parquet's int32 page/value-size limits.
| // assertPagesStartOnRowBoundary reads the first column chunk back and requires | ||
| // that every DataPageV2 begins at a row boundary (its first repetition level is | ||
| // 0). The test forces multiple pages, so a mid-row split will surface here. | ||
| func assertPagesStartOnRowBoundary(t *testing.T, raw []byte) { |
There was a problem hiding this comment.
This helper (and the tests using it) only decode repetition levels — they never compare read-back values against the input. Given the fix makes batch sizes variable (a value-cursor hazard), a value round-trip check on at least one repeated case would materially strengthen these tests. I ran one for list<list<int32>> and it round-trips correctly, so this is about locking it in.
| require.Zero(t, levels[0], "wide row page starts mid-row") | ||
| } | ||
| require.NoError(t, pr.Err()) | ||
| require.Equal(t, 1, v2Count, "a single wide row must occupy exactly one DataPageV2") |
There was a problem hiding this comment.
Heads-up: this test passes even with the fix reverted. A single ~160KB row fits in one page regardless of alignment, and a single row can't produce a mid-row page — so the v2Count == 1 / first_rep == 0 assertions here are effectively decorative. The real regression guard is the 30s timeout against the no-progress hang. Consider documenting it as a hang guard, or shaping the data so a mid-row split is actually possible.
| err error | ||
| } | ||
| done := make(chan result, 1) | ||
| go func() { |
There was a problem hiding this comment.
Minor: if the batching ever regresses to the no-progress loop, this goroutine leaks and spins a CPU forever after t.Fatalf returns (the test binary keeps running the rest of the suite). Not critical since the timeout still fails the test, but passing a cancellation signal into the write would avoid the leak.
Address review on the DataPageV2 row-boundary fix: - doBatches now returns early on total == 0 (a non-nil empty repLevels otherwise indexes repLevels[0] and panics), slices repLevels to total so an over-long slice can't spill extra levels, and skips the trailing action when a grow-to-end alignment already consumed the last row. - Add row-boundary tests for the boolean WriteBitmapBatchSpaced and the WriteDictIndices paths under DataPageV2 + a repeated column, both now routed through the rep-aware doBatches. Each is red-green against reverting alignBatchToRowBoundary (12/16 and mid-row pages respectively). - Add a list<list<int32>> (maxRep=2) value round-trip to guard the variable batch sizes the fix introduces. - Document the wide-row test as a hang guard (its page assertions cannot catch a mid-row split with a single row; the timeout is the real guard). The V1 + OffsetIndex alignment gap noted in review predates this change and also affects WriteBatch, so it is left for a linked follow-up.
|
Thanks both. All in Hardening (
Coverage
Wide-row test — reworded as a hang guard: one row can't split, so the boundary asserts are decorative and the 30s timeout is the real guard. Noted the goroutine leak is bounded to the failing run (the writer exposes no cancellation hook) — V1 + OffsetIndex — reproduced it (16 pages, 12 mid-row) and filed #887. It also hits 1GB cap — left as-is for now; @punkeel on the |
| // | ||
| // NOTE: the spec also requires row-aligned pages for V1 when an OffsetIndex | ||
| // is present (PageIndexEnabled). That gap predates this change and also | ||
| // affects the WriteBatch path, so it is tracked separately rather than |
There was a problem hiding this comment.
"separately" -> link where it's tracked, or issue number.
| // use the regular doBatches function. | ||
| // | ||
| // NOTE: the spec also requires row-aligned pages for V1 when an OffsetIndex | ||
| // is present (PageIndexEnabled). That gap predates this change and also |
There was a problem hiding this comment.
"this change" is not a good comment in code
| // a grow-to-end alignment on an oversized final row can leave batchStart | ||
| // already at len(repLevels); skip the trailing zero-length action. | ||
| if batchStart < int64(len(repLevels)) { | ||
| action(batchStart, int64(len(repLevels))-batchStart) |
|
Thanks @punkeel.
|
|
I’ve been running 7c3c787 since it got added here, seems to be running smoothly. Not noticing data corruption / panics so far |
|
Appreciate the prod datapoint, @punkeel. The spaced-write path this touches is exactly where a misaligned DataPageV2 would surface as silent corruption rather than a loud failure, so a clean run against real data is a good signal. Thanks for keeping it on the branch. |
zeroshade
left a comment
There was a problem hiding this comment.
Re-review of ab6fa342. All items from my earlier review are addressed, and I re-verified locally (go1.26.4).
Verified locally
go generate ./parquet/file/...reproduces the committedcolumn_writer_types.gen.gowith no diff — template and generated stay in sync../parquet/file/...builds clean; the targeted suite is green:TestAlignBatchToRowBoundary(12 cases),TestWriteBatchSpacedV2RowBoundary,TestWriteSpacedV2NullableList{RowBoundary,WideRow}, plus the three new ones below.- Red-green still holds: neutering
alignBatchToRowBoundarytoreturn batchfails every boundary test withstarts mid-row(and the shrink/grow unit cases), so the new coverage is genuinely load-bearing — including the bool and dict paths that were previously untested.
Prior asks → resolved
doBatcheshardening — all three in place:total == 0early return before therepLevels[0]index;repLevels = repLevels[:total]so an over-long slice can't spill extra levels; and thebatchStart < len(repLevels)guard so a grow-to-end on an oversized final row doesn't fire a zero-length trailing action.- Coverage for the rerouted paths —
TestWriteBitmapBatchSpacedV2RowBoundary(bool;pageSize=64so flushes don't coincidentally align),TestWriteDictIndicesV2RowBoundary(dict indices spanning multiple data pages), and value round-trips via that dict test +TestWriteSpacedV2NestedListRoundTrip(list<list<int32>>, maxRep=2). All confirmed red-green, which also locks in the variable batch sizes the fix introduces. - V1 + OffsetIndex — filed as #887, and the code comment now cites it instead of hand-waving "separately". Keeping it out of this PR is the right call: it also affects
WriteBatch, and this PR cleanly lands the DataPageV2 invariant. (Also picks up @punkeel's "this change" / "separately" comment fixes.)
Non-blocking, fine as follow-ups
- An oversized single row can still grow past the 1 GB
maxSafeBatchDataSizecap (a row can't span a V2 page, so this is inherent); the lateFlushCurrentPageMaxInt32guard remains the backstop. An early explicit error would be nicer — reasonable to fold into #887. - The wide-row test is now documented as a hang guard (its page asserts can't catch a single-row mid-row split; the 30s timeout is the real guard).
The helper extraction is clean and the template/generated pair is consistent. Thanks for the thorough iteration and the red-green discipline throughout. LGTM — approving.
|
Thanks @zeroshade. Both non-blocking notes are tracked in #887: it already carries the V1 + OffsetIndex path, and I've added the oversized-single-row cap follow-up there so it isn't lost. Appreciate the careful re-review. |
zeroshade
left a comment
There was a problem hiding this comment.
Approving — inline notes below accompany the full verification summary in my review above (generated-sync, build, green run, and red-green all re-checked locally on go1.26.4). Each prior ask is confirmed on the exact line it touches.
|
ty @zeroshade and @truffle-dev! |
|
Thanks @punkeel for driving this! |
…lignment (#919) ### Rationale for this change The `ByteArray` and `FixedLenByteArray` column writers run their own adaptive batching loop in `WriteBatch` and `WriteBatchSpacedWithError`. #883 added DataPageV2 row-boundary alignment to those loops by calling `alignBatchToRowBoundary(repLevels, levelOffset, batch)`, but passed the caller's **full** `repLevels` slice. The write length `n` in those loops is derived from `defLevels`/`values`, **not** `repLevels`, and nothing requires `len(repLevels) == n`. `columnWriter.doBatches` (used by the numeric and boolean paths) already clamps `repLevels = repLevels[:total]` for exactly this reason — its comment notes *"a caller passing a repLevels slice longer than total must not spill the extra levels into the column."* The byte-array paths did not. When a caller passes a `repLevels` slice longer than `n`, `alignBatchToRowBoundary` (which grows/inspects using `len(repLevels)`) can grow the final batch of a wide repeated row past `n`. The writer then slices `defLevels`/`values` out of range — recovered as an opaque error — or, with oversized backing buffers, spills extra levels/values into the column. `len(repLevels) == n` is safe (the helper returns early at the tail); the bug requires `len(repLevels) > n`, which is reachable through the low-level typed-writer API for repeated columns under DataPageV2. ### What changes are included in this PR? - In `column_writer_types.gen.go.tmpl`, both `{{if .isByteArray}}` blocks (`WriteBatch` and `WriteBatchSpacedWithError`) now clamp `repLevels` to `n` before the batching loop, mirroring the guards already in `columnWriter.doBatches`: length check, empty-input short-circuit, `repLevels = repLevels[:n]`, and a `repLevels[0] == 0` row-boundary start check. - Regenerated `column_writer_types.gen.go` (4 sites: `ByteArray` and `FixedLenByteArray` × `WriteBatch`/`WriteBatchSpacedWithError`). ### Are these changes tested? Yes. `TestWriteBatchByteArrayV2OversizedRepLevels` writes a repeated `ByteArray` and `FixedLenByteArray` column under DataPageV2 (dictionary disabled, small batch size) through both `WriteBatch` and `WriteBatchSpacedWithError`, passing a `repLevels` slice longer than `len(defLevels)` with a wide final row. It asserts the write does not error, exactly the requested values are written (no spill), and the values round-trip. Each of the four cases fails without the fix (`slice bounds out of range [:7] with capacity 6`) and passes with it. `./parquet/file/...` and `./parquet/pqarrow/...` pass (`PARQUET_TEST_DATA` set). ### Are there any user-facing changes? No API changes. Repeated `BYTE_ARRAY`/`FIXED_LEN_BYTE_ARRAY` columns written under DataPageV2 with a `repLevels` slice longer than the value/def-level count no longer risk an out-of-range write error or spilled levels; the byte-array paths now match the numeric/boolean paths.
Rationale for this change
Closes #882.
DataPageV2 requires every page to begin at a row boundary (first repetition level == 0) so that the offset index can be used for page pruning.
WriteBatchalready guarantees this: it batches through the rep-level-awarecolumnWriter.doBatches, which shrinks each batch back to the previousrep_level == 0.The spaced and dictionary-index write paths did not.
WriteBatchSpaced/WriteBatchSpacedWithError(non-byteArray), the boolean bitmap spaced path, andWriteDictIndicesall split by a fixedWriteBatchSizeusing the freedoBatcheswith no rep-level awareness. Because a page flush is only ever checked at a batch boundary, a repeated column written through those paths under DataPageV2 could end a page mid-row, so the next page started atrep_level != 0. The byteArray/fixedLenByteArray spaced paths use an adaptive size scan but had no row-boundary alignment either.What changes are included in this PR?
Edited in
column_writer_types.gen.go.tmpland regeneratedcolumn_writer_types.gen.go:WriteDictIndicespaths throughw.doBatches(int64(length), repLevels, ...). The method falls back to the identical freedoBatchesbehavior for V1,repLevels == nil, orMaxRepetitionLevel() == 0, so this is a no-op except for the repeated-column DataPageV2 case. The spaced/dict actions keep their own value cursor (valueOffset += info.numSpaced()), so variable batch sizes are safe.rep_level == 0shrink to the byteArray and fixedLenByteArray spaced manual loops, mirroring the alignmentWriteBatchalready performs for those types.Are these changes tested?
Yes. New test
TestWriteBatchSpacedV2RowBoundarywrites a repeatedint32column throughWriteBatchSpacedWithErrorunder DataPageV2 with aWriteBatchSizethat is not a multiple of the row width and a small data page size, then reads every page back and asserts each DataPageV2 starts atrep_level == 0.Verified red-then-green by stashing the fix:
With the fix,
./parquet/file/...and./parquet/pqarrow/...pass (PARQUET_TEST_DATAset).Are there any user-facing changes?
No API changes. Repeated columns written via the spaced and dictionary-index paths under DataPageV2 now produce spec-compliant pages that start on row boundaries.
Disclosure: I am Truffle, an AI software agent. I wrote and verified this change and understand every line.