This is an automated email from the ASF dual-hosted git repository.

zeroshade pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/arrow-go.git


The following commit(s) were added to refs/heads/main by this push:
     new ef004bc9 fix(parquet): clamp repLevels in byte-array DataPageV2 
row-boundary alignment (#919)
ef004bc9 is described below

commit ef004bc97dc61d788479be807a0ac0cd97951642
Author: Matt Topol <[email protected]>
AuthorDate: Fri Jul 10 12:30:00 2026 -0400

    fix(parquet): clamp repLevels in byte-array DataPageV2 row-boundary 
alignment (#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.
---
 parquet/file/column_writer_types.gen.go            |  62 ++++++++++
 parquet/file/column_writer_types.gen.go.tmpl       |  31 +++++
 parquet/file/column_writer_v2_row_boundary_test.go | 133 +++++++++++++++++++++
 3 files changed, 226 insertions(+)

diff --git a/parquet/file/column_writer_types.gen.go 
b/parquet/file/column_writer_types.gen.go
index 9a39615b..4ae88bf6 100644
--- a/parquet/file/column_writer_types.gen.go
+++ b/parquet/file/column_writer_types.gen.go
@@ -1810,6 +1810,23 @@ func (w *ByteArrayColumnChunkWriter) WriteBatch(values 
[]parquet.ByteArray, defL
                repLevels != nil && w.descr.MaxRepetitionLevel() > 0
        levelOffset := int64(0)
 
+       // Repeated DataPageV2 writes align batches on row boundaries using 
repLevels
+       // below. n comes from defLevels/values, not repLevels, so clamp 
repLevels to n
+       // to stop an oversized slice from growing a batch past n (spilling 
extra levels
+       // or reading out of range). Mirrors columnWriter.doBatches.
+       if isV2WithRep {
+               if int64(len(repLevels)) < n {
+                       panic("columnwriter: not enough repetition levels for 
batch to write")
+               }
+               if n == 0 {
+                       return
+               }
+               repLevels = repLevels[:n]
+               if repLevels[0] != 0 {
+                       panic("columnwriter: batch writing for V2 data pages 
must start at a row boundary")
+               }
+       }
+
        for levelOffset < n {
                remaining := n - levelOffset
                batch := min(remaining, batchSize)
@@ -1908,6 +1925,20 @@ func (w *ByteArrayColumnChunkWriter) 
WriteBatchSpacedWithError(values []parquet.
        levelOffset := int64(0)
        n := int64(length)
 
+       // Clamp repLevels to n; see WriteBatch. Mirrors columnWriter.doBatches.
+       if isV2WithRep {
+               if int64(len(repLevels)) < n {
+                       panic("columnwriter: not enough repetition levels for 
batch to write")
+               }
+               if n == 0 {
+                       return
+               }
+               repLevels = repLevels[:n]
+               if repLevels[0] != 0 {
+                       panic("columnwriter: batch writing for V2 data pages 
must start at a row boundary")
+               }
+       }
+
        for levelOffset < n {
                remaining := n - levelOffset
                batch := min(remaining, batchSize)
@@ -2149,6 +2180,23 @@ func (w *FixedLenByteArrayColumnChunkWriter) 
WriteBatch(values []parquet.FixedLe
                repLevels != nil && w.descr.MaxRepetitionLevel() > 0
        levelOffset := int64(0)
 
+       // Repeated DataPageV2 writes align batches on row boundaries using 
repLevels
+       // below. n comes from defLevels/values, not repLevels, so clamp 
repLevels to n
+       // to stop an oversized slice from growing a batch past n (spilling 
extra levels
+       // or reading out of range). Mirrors columnWriter.doBatches.
+       if isV2WithRep {
+               if int64(len(repLevels)) < n {
+                       panic("columnwriter: not enough repetition levels for 
batch to write")
+               }
+               if n == 0 {
+                       return
+               }
+               repLevels = repLevels[:n]
+               if repLevels[0] != 0 {
+                       panic("columnwriter: batch writing for V2 data pages 
must start at a row boundary")
+               }
+       }
+
        for levelOffset < n {
                remaining := n - levelOffset
                batch := min(remaining, batchSize)
@@ -2247,6 +2295,20 @@ func (w *FixedLenByteArrayColumnChunkWriter) 
WriteBatchSpacedWithError(values []
        levelOffset := int64(0)
        n := int64(length)
 
+       // Clamp repLevels to n; see WriteBatch. Mirrors columnWriter.doBatches.
+       if isV2WithRep {
+               if int64(len(repLevels)) < n {
+                       panic("columnwriter: not enough repetition levels for 
batch to write")
+               }
+               if n == 0 {
+                       return
+               }
+               repLevels = repLevels[:n]
+               if repLevels[0] != 0 {
+                       panic("columnwriter: batch writing for V2 data pages 
must start at a row boundary")
+               }
+       }
+
        for levelOffset < n {
                remaining := n - levelOffset
                batch := min(remaining, batchSize)
diff --git a/parquet/file/column_writer_types.gen.go.tmpl 
b/parquet/file/column_writer_types.gen.go.tmpl
index bf1f2c51..d23bdfa3 100644
--- a/parquet/file/column_writer_types.gen.go.tmpl
+++ b/parquet/file/column_writer_types.gen.go.tmpl
@@ -98,6 +98,23 @@ func (w *{{.Name}}ColumnChunkWriter) WriteBatch(values 
[]{{.name}}, defLevels, r
     repLevels != nil && w.descr.MaxRepetitionLevel() > 0
   levelOffset := int64(0)
 
+  // Repeated DataPageV2 writes align batches on row boundaries using repLevels
+  // below. n comes from defLevels/values, not repLevels, so clamp repLevels 
to n
+  // to stop an oversized slice from growing a batch past n (spilling extra 
levels
+  // or reading out of range). Mirrors columnWriter.doBatches.
+  if isV2WithRep {
+    if int64(len(repLevels)) < n {
+      panic("columnwriter: not enough repetition levels for batch to write")
+    }
+    if n == 0 {
+      return
+    }
+    repLevels = repLevels[:n]
+    if repLevels[0] != 0 {
+      panic("columnwriter: batch writing for V2 data pages must start at a row 
boundary")
+    }
+  }
+
   for levelOffset < n {
     remaining := n - levelOffset
     batch := min(remaining, batchSize)
@@ -219,6 +236,20 @@ func (w *{{.Name}}ColumnChunkWriter) 
WriteBatchSpacedWithError(values []{{.name}
   levelOffset := int64(0)
   n := int64(length)
 
+  // Clamp repLevels to n; see WriteBatch. Mirrors columnWriter.doBatches.
+  if isV2WithRep {
+    if int64(len(repLevels)) < n {
+      panic("columnwriter: not enough repetition levels for batch to write")
+    }
+    if n == 0 {
+      return
+    }
+    repLevels = repLevels[:n]
+    if repLevels[0] != 0 {
+      panic("columnwriter: batch writing for V2 data pages must start at a row 
boundary")
+    }
+  }
+
   for levelOffset < n {
     remaining := n - levelOffset
     batch := min(remaining, batchSize)
diff --git a/parquet/file/column_writer_v2_row_boundary_test.go 
b/parquet/file/column_writer_v2_row_boundary_test.go
index 89dcec7d..aa8e4b39 100644
--- a/parquet/file/column_writer_v2_row_boundary_test.go
+++ b/parquet/file/column_writer_v2_row_boundary_test.go
@@ -520,3 +520,136 @@ func requireRecordRoundTrips(t *testing.T, arrowSchema 
*arrow.Schema, rec arrow.
        defer merged.Release()
        require.Truef(t, array.Equal(want, merged), "round-trip mismatch\n 
want: %v\n got:  %v", want, merged)
 }
+
+// TestWriteBatchByteArrayV2OversizedRepLevels is a regression test for the
+// byte-array and fixed-len-byte-array counterpart of #883's DataPageV2
+// row-boundary alignment. Those writers run their own adaptive batching loop 
and
+// passed the caller's full repLevels slice to alignBatchToRowBoundary, while 
the
+// write length n is derived from defLevels/values. When repLevels is longer 
than
+// n - which the low-level typed-writer API permits, and which
+// columnWriter.doBatches already clamps against for the numeric/boolean paths 
-
+// the alignment of a wide final row could grow the last batch past n, slicing
+// defLevels/values out of range (recovered as an opaque error) or spilling 
extra
+// levels into the column. The byte-array paths must clamp repLevels to n too.
+func TestWriteBatchByteArrayV2OversizedRepLevels(t *testing.T) {
+       const batchSize = 3
+
+       // n = 6 requested levels. repLevels is deliberately longer (8) and its 
final
+       // row is wide, so unclamped alignment would grow the last batch past n.
+       // Clamped to repLevels[:6] = {0,1,1,0,1,1} the data is two rows of 
three.
+       defLevels := []int16{1, 1, 1, 1, 1, 1}
+       repLevels := []int16{0, 1, 1, 0, 1, 1, 1, 0}
+       validBits := []byte{0xFF}
+       wantVals := []string{"a", "b", "c", "d", "e", "f"}
+       const wantRows = 2
+
+       byteArrayVals := []parquet.ByteArray{[]byte("a"), []byte("b"), 
[]byte("c"), []byte("d"), []byte("e"), []byte("f")}
+       flbaVals := []parquet.FixedLenByteArray{[]byte("a"), []byte("b"), 
[]byte("c"), []byte("d"), []byte("e"), []byte("f")}
+
+       props := parquet.NewWriterProperties(
+               parquet.WithVersion(parquet.V2_LATEST),
+               parquet.WithDataPageVersion(parquet.DataPageV2),
+               parquet.WithDictionaryDefault(false),
+               parquet.WithBatchSize(batchSize),
+               parquet.WithCompression(compress.Codecs.Uncompressed),
+       )
+
+       cases := []struct {
+               name  string
+               node  *schema.PrimitiveNode
+               write func(cw file.ColumnChunkWriter) (int64, error)
+               read  func(t *testing.T, ccr file.ColumnChunkReader) []string
+       }{
+               {
+                       name: "ByteArray/WriteBatch",
+                       node: schema.NewByteArrayNode("v", 
parquet.Repetitions.Repeated, -1),
+                       write: func(cw file.ColumnChunkWriter) (int64, error) {
+                               return 
cw.(*file.ByteArrayColumnChunkWriter).WriteBatch(byteArrayVals, defLevels, 
repLevels)
+                       },
+                       read: readByteArrayValues,
+               },
+               {
+                       name: "ByteArray/WriteBatchSpaced",
+                       node: schema.NewByteArrayNode("v", 
parquet.Repetitions.Repeated, -1),
+                       write: func(cw file.ColumnChunkWriter) (int64, error) {
+                               return 
cw.(*file.ByteArrayColumnChunkWriter).WriteBatchSpacedWithError(byteArrayVals, 
defLevels, repLevels, validBits, 0)
+                       },
+                       read: readByteArrayValues,
+               },
+               {
+                       name: "FixedLenByteArray/WriteBatch",
+                       node: schema.NewFixedLenByteArrayNode("v", 
parquet.Repetitions.Repeated, 1, -1),
+                       write: func(cw file.ColumnChunkWriter) (int64, error) {
+                               return 
cw.(*file.FixedLenByteArrayColumnChunkWriter).WriteBatch(flbaVals, defLevels, 
repLevels)
+                       },
+                       read: readFixedLenByteArrayValues,
+               },
+               {
+                       name: "FixedLenByteArray/WriteBatchSpaced",
+                       node: schema.NewFixedLenByteArrayNode("v", 
parquet.Repetitions.Repeated, 1, -1),
+                       write: func(cw file.ColumnChunkWriter) (int64, error) {
+                               return 
cw.(*file.FixedLenByteArrayColumnChunkWriter).WriteBatchSpacedWithError(flbaVals,
 defLevels, repLevels, validBits, 0)
+                       },
+                       read: readFixedLenByteArrayValues,
+               },
+       }
+
+       for _, tc := range cases {
+               t.Run(tc.name, func(t *testing.T) {
+                       root, err := schema.NewGroupNode("schema", 
parquet.Repetitions.Required, schema.FieldList{tc.node}, -1)
+                       require.NoError(t, err)
+
+                       var buf bytes.Buffer
+                       w := file.NewParquetWriter(&buf, root, 
file.WithWriterProps(props))
+                       rgw := w.AppendRowGroup()
+                       cw, err := rgw.NextColumn()
+                       require.NoError(t, err)
+
+                       valueOffset, err := tc.write(cw)
+                       require.NoError(t, err, "oversized repLevels must not 
fail the write")
+                       require.EqualValues(t, len(wantVals), valueOffset, 
"must write exactly the requested values, no spill past n")
+                       require.NoError(t, cw.Close())
+                       require.NoError(t, rgw.Close())
+                       require.NoError(t, w.Close())
+
+                       r, err := 
file.NewParquetReader(bytes.NewReader(buf.Bytes()))
+                       require.NoError(t, err)
+                       defer r.Close()
+
+                       require.EqualValues(t, wantRows, r.NumRows())
+                       cc, err := r.MetaData().RowGroup(0).ColumnChunk(0)
+                       require.NoError(t, err)
+                       require.EqualValues(t, len(wantVals), cc.NumValues(), 
"column must hold exactly the requested values, no spill")
+
+                       ccr, err := r.RowGroup(0).Column(0)
+                       require.NoError(t, err)
+                       require.Equal(t, wantVals, tc.read(t, ccr))
+               })
+       }
+}
+
+func readByteArrayValues(t *testing.T, ccr file.ColumnChunkReader) []string {
+       t.Helper()
+       r := ccr.(*file.ByteArrayColumnChunkReader)
+       vals := make([]parquet.ByteArray, 16)
+       _, read, err := r.ReadBatch(int64(len(vals)), vals, nil, nil)
+       require.NoError(t, err)
+       out := make([]string, read)
+       for i := 0; i < read; i++ {
+               out[i] = string(vals[i])
+       }
+       return out
+}
+
+func readFixedLenByteArrayValues(t *testing.T, ccr file.ColumnChunkReader) 
[]string {
+       t.Helper()
+       r := ccr.(*file.FixedLenByteArrayColumnChunkReader)
+       vals := make([]parquet.FixedLenByteArray, 16)
+       _, read, err := r.ReadBatch(int64(len(vals)), vals, nil, nil)
+       require.NoError(t, err)
+       out := make([]string, read)
+       for i := 0; i < read; i++ {
+               out[i] = string(vals[i])
+       }
+       return out
+}

Reply via email to