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 6de63de9 fix(parquet): row-align V1 pages with offset indexes (#1075)
6de63de9 is described below

commit 6de63de933edf80c96fefc311a461e8b96f9c914
Author: Minh Vu <[email protected]>
AuthorDate: Wed Aug 5 20:16:29 2026 +0200

    fix(parquet): row-align V1 pages with offset indexes (#1075)
    
    Parquet pages referenced by an OffsetIndex must begin at row boundaries. The
    writer enforced this for repeated DataPageV2 columns, but repeated 
DataPageV1
    columns still used fixed-size batching when page indexes were enabled, so a
    logical row could span two indexed V1 pages.
    
    Route repeated V1 columns through the existing repetition-level-aware 
batching
    when page indexing is enabled, and apply the same condition to the generated
    variable-length writer paths. V1 writes without page indexes retain the
    existing fixed-size batching behavior.
    
    Adds a regression test using indexed V1 pages with repeated three-value rows
    and a batch size that does not divide the row width, verifying intermediate
    and final pages all begin at row boundaries.
    
    Closes #887
---
 parquet/file/column_writer.go                | 15 +++------
 parquet/file/column_writer_test.go           | 41 ++++++++++++++++++++++++
 parquet/file/column_writer_types.gen.go      | 48 +++++++++++++++-------------
 parquet/file/column_writer_types.gen.go.tmpl | 24 +++++++-------
 4 files changed, 85 insertions(+), 43 deletions(-)

diff --git a/parquet/file/column_writer.go b/parquet/file/column_writer.go
index bc8cd701..b19d1ce9 100644
--- a/parquet/file/column_writer.go
+++ b/parquet/file/column_writer.go
@@ -656,14 +656,9 @@ func (w *columnWriter) Close() (err error) {
 
 func (w *columnWriter) doBatches(total int64, repLevels []int16, action 
func(offset, batch int64)) {
        batchSize := w.props.WriteBatchSize()
-       // if we're writing V1 data pages, have no replevels or the max 
replevel is 0 then just
-       // use the regular doBatches function.
-       //
-       // The spec also requires row-aligned pages for V1 when an OffsetIndex 
is
-       // present (PageIndexEnabled). That gap is on the WriteBatch path and is
-       // out of scope for the DataPageV2 row alignment here; it is tracked in
-       // https://github.com/apache/arrow-go/issues/887.
-       if w.props.DataPageVersion() == parquet.DataPageV1 || repLevels == nil 
|| w.descr.MaxRepetitionLevel() == 0 {
+       requiresRowAlignment := w.props.DataPageVersion() != parquet.DataPageV1 
||
+               w.props.PageIndexEnabledFor(w.descr.Path())
+       if !requiresRowAlignment || repLevels == nil || 
w.descr.MaxRepetitionLevel() == 0 {
                doBatches(total, batchSize, action)
                return
        }
@@ -692,7 +687,7 @@ func (w *columnWriter) doBatches(total int64, repLevels 
[]int16, action func(off
        repLevels = repLevels[:total]
 
        if repLevels[0] != 0 {
-               panic("columnwriter: batch writing for V2 data pages must start 
at a row boundary")
+               panic("columnwriter: row-aligned batch writing must start at a 
row boundary")
        }
 
        // loop by batchSize, but make sure we're ending/starting each batch on 
a row boundary
@@ -723,7 +718,7 @@ func doBatches(total, batchSize int64, action func(offset, 
batch int64)) {
 
 // alignBatchToRowBoundary adjusts batch so that repLevels[offset+batch] lands 
on
 // a row boundary (repetition level 0) or the end of the level slice. A 
repeated
-// row must never span a DataPageV2 page boundary, so it first shrinks toward 
the
+// row must never span a row-aligned page boundary, so it first shrinks toward 
the
 // previous boundary. If there is no boundary at or before the requested split 
-
 // the current row is wider than batch - it grows forward to the next one so 
the
 // whole row stays in a single batch and the caller keeps making progress 
rather
diff --git a/parquet/file/column_writer_test.go 
b/parquet/file/column_writer_test.go
index a649a8c1..7f66f79f 100644
--- a/parquet/file/column_writer_test.go
+++ b/parquet/file/column_writer_test.go
@@ -222,6 +222,47 @@ func TestDataPageV2RowBoundaries(t *testing.T) {
        wr.WriteBatch(values, defLevels, repLevels)
 }
 
+func TestDataPageV1OffsetIndexRowBoundaries(t *testing.T) {
+       sc := schema.NewSchema(schema.MustGroup(schema.NewGroupNode("schema", 
parquet.Repetitions.Required, schema.FieldList{
+               schema.Must(schema.ListOf(
+                       schema.Must(schema.NewPrimitiveNode("column", 
parquet.Repetitions.Optional, parquet.Types.Int32, -1, -1)),
+                       parquet.Repetitions.Optional, -1)),
+       }, -1)))
+       descr := sc.Column(0)
+       props := parquet.NewWriterProperties(
+               parquet.WithBatchSize(128),
+               parquet.WithDataPageSize(1024),
+               parquet.WithDataPageVersion(parquet.DataPageV1),
+               parquet.WithPageIndexEnabled(true),
+               parquet.WithDictionaryDefault(false))
+
+       metadata := metadata.NewColumnChunkMetaDataBuilder(props, descr)
+       pager := new(mockpagewriter)
+       defer pager.AssertExpectations(t)
+       pager.On("HasCompressor").Return(false)
+       wr := file.NewColumnChunkWriter(metadata, pager, 
props).(*file.Int32ColumnChunkWriter)
+
+       pager.On("WriteDataPage", mock.MatchedBy(func(page file.DataPage) bool {
+               pagev1, ok := page.(*file.DataPageV1)
+               return ok && pagev1.NumValues()%3 == 0
+       })).Return(10, nil)
+       pager.On("Close", false, false).Return(nil).Once()
+
+       values := make([]int32, 1023)
+       defLevels := make([]int16, 1023)
+       repLevels := make([]int16, 1023)
+       for i := range values {
+               values[i] = int32(i)
+               defLevels[i] = 3
+               if i%3 != 0 {
+                       repLevels[i] = 1
+               }
+       }
+
+       wr.WriteBatch(values, defLevels, repLevels)
+       assert.NoError(t, wr.Close())
+}
+
 type PrimitiveWriterTestSuite struct {
        testutils.PrimitiveTypedTest
        suite.Suite
diff --git a/parquet/file/column_writer_types.gen.go 
b/parquet/file/column_writer_types.gen.go
index 4ae88bf6..d0dc0a5c 100644
--- a/parquet/file/column_writer_types.gen.go
+++ b/parquet/file/column_writer_types.gen.go
@@ -1806,15 +1806,16 @@ func (w *ByteArrayColumnChunkWriter) WriteBatch(values 
[]parquet.ByteArray, defL
 
        batchSize := w.props.WriteBatchSize()
        maxDefLevel := w.descr.MaxDefinitionLevel()
-       isV2WithRep := w.props.DataPageVersion() != parquet.DataPageV1 &&
+       requiresRowAlignment := (w.props.DataPageVersion() != 
parquet.DataPageV1 ||
+               w.props.PageIndexEnabledFor(w.descr.Path())) &&
                repLevels != nil && w.descr.MaxRepetitionLevel() > 0
        levelOffset := int64(0)
 
-       // Repeated DataPageV2 writes align batches on row boundaries using 
repLevels
+       // Repeated writes that produce an offset index align batches on row 
boundaries
        // 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 requiresRowAlignment {
                if int64(len(repLevels)) < n {
                        panic("columnwriter: not enough repetition levels for 
batch to write")
                }
@@ -1823,7 +1824,7 @@ func (w *ByteArrayColumnChunkWriter) WriteBatch(values 
[]parquet.ByteArray, defL
                }
                repLevels = repLevels[:n]
                if repLevels[0] != 0 {
-                       panic("columnwriter: batch writing for V2 data pages 
must start at a row boundary")
+                       panic("columnwriter: row-aligned batch writing must 
start at a row boundary")
                }
        }
 
@@ -1850,10 +1851,10 @@ func (w *ByteArrayColumnChunkWriter) WriteBatch(values 
[]parquet.ByteArray, defL
                        }
                }
 
-               // V2 row-boundary alignment: a repeated row must not span a 
page boundary,
+               // Row-boundary alignment: a repeated row must not span an 
indexed page boundary,
                // so snap the batch onto the nearest row boundary (or keep a 
single wide row
                // whole). See alignBatchToRowBoundary in column_writer.go.
-               if isV2WithRep {
+               if requiresRowAlignment {
                        batch = alignBatchToRowBoundary(repLevels, levelOffset, 
batch)
                }
                if batch < 1 {
@@ -1920,13 +1921,14 @@ func (w *ByteArrayColumnChunkWriter) 
WriteBatchSpacedWithError(values []parquet.
        const maxSafeBatchDataSize int64 = 1 << 30 // 1GB
 
        batchSize := w.props.WriteBatchSize()
-       isV2WithRep := w.props.DataPageVersion() != parquet.DataPageV1 &&
+       requiresRowAlignment := (w.props.DataPageVersion() != 
parquet.DataPageV1 ||
+               w.props.PageIndexEnabledFor(w.descr.Path())) &&
                repLevels != nil && w.descr.MaxRepetitionLevel() > 0
        levelOffset := int64(0)
        n := int64(length)
 
        // Clamp repLevels to n; see WriteBatch. Mirrors columnWriter.doBatches.
-       if isV2WithRep {
+       if requiresRowAlignment {
                if int64(len(repLevels)) < n {
                        panic("columnwriter: not enough repetition levels for 
batch to write")
                }
@@ -1935,7 +1937,7 @@ func (w *ByteArrayColumnChunkWriter) 
WriteBatchSpacedWithError(values []parquet.
                }
                repLevels = repLevels[:n]
                if repLevels[0] != 0 {
-                       panic("columnwriter: batch writing for V2 data pages 
must start at a row boundary")
+                       panic("columnwriter: row-aligned batch writing must 
start at a row boundary")
                }
        }
 
@@ -1955,10 +1957,10 @@ func (w *ByteArrayColumnChunkWriter) 
WriteBatchSpacedWithError(values []parquet.
                        }
                }
 
-               // V2 row-boundary alignment: a repeated row must not span a 
page boundary,
+               // Row-boundary alignment: a repeated row must not span an 
indexed page boundary,
                // so snap the batch onto the nearest row boundary (or keep a 
single wide row
                // whole). See alignBatchToRowBoundary in column_writer.go.
-               if isV2WithRep {
+               if requiresRowAlignment {
                        batch = alignBatchToRowBoundary(repLevels, levelOffset, 
batch)
                }
                if batch < 1 {
@@ -2176,15 +2178,16 @@ func (w *FixedLenByteArrayColumnChunkWriter) 
WriteBatch(values []parquet.FixedLe
 
        batchSize := w.props.WriteBatchSize()
        maxDefLevel := w.descr.MaxDefinitionLevel()
-       isV2WithRep := w.props.DataPageVersion() != parquet.DataPageV1 &&
+       requiresRowAlignment := (w.props.DataPageVersion() != 
parquet.DataPageV1 ||
+               w.props.PageIndexEnabledFor(w.descr.Path())) &&
                repLevels != nil && w.descr.MaxRepetitionLevel() > 0
        levelOffset := int64(0)
 
-       // Repeated DataPageV2 writes align batches on row boundaries using 
repLevels
+       // Repeated writes that produce an offset index align batches on row 
boundaries
        // 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 requiresRowAlignment {
                if int64(len(repLevels)) < n {
                        panic("columnwriter: not enough repetition levels for 
batch to write")
                }
@@ -2193,7 +2196,7 @@ func (w *FixedLenByteArrayColumnChunkWriter) 
WriteBatch(values []parquet.FixedLe
                }
                repLevels = repLevels[:n]
                if repLevels[0] != 0 {
-                       panic("columnwriter: batch writing for V2 data pages 
must start at a row boundary")
+                       panic("columnwriter: row-aligned batch writing must 
start at a row boundary")
                }
        }
 
@@ -2220,10 +2223,10 @@ func (w *FixedLenByteArrayColumnChunkWriter) 
WriteBatch(values []parquet.FixedLe
                        }
                }
 
-               // V2 row-boundary alignment: a repeated row must not span a 
page boundary,
+               // Row-boundary alignment: a repeated row must not span an 
indexed page boundary,
                // so snap the batch onto the nearest row boundary (or keep a 
single wide row
                // whole). See alignBatchToRowBoundary in column_writer.go.
-               if isV2WithRep {
+               if requiresRowAlignment {
                        batch = alignBatchToRowBoundary(repLevels, levelOffset, 
batch)
                }
                if batch < 1 {
@@ -2290,13 +2293,14 @@ func (w *FixedLenByteArrayColumnChunkWriter) 
WriteBatchSpacedWithError(values []
        const maxSafeBatchDataSize int64 = 1 << 30 // 1GB
 
        batchSize := w.props.WriteBatchSize()
-       isV2WithRep := w.props.DataPageVersion() != parquet.DataPageV1 &&
+       requiresRowAlignment := (w.props.DataPageVersion() != 
parquet.DataPageV1 ||
+               w.props.PageIndexEnabledFor(w.descr.Path())) &&
                repLevels != nil && w.descr.MaxRepetitionLevel() > 0
        levelOffset := int64(0)
        n := int64(length)
 
        // Clamp repLevels to n; see WriteBatch. Mirrors columnWriter.doBatches.
-       if isV2WithRep {
+       if requiresRowAlignment {
                if int64(len(repLevels)) < n {
                        panic("columnwriter: not enough repetition levels for 
batch to write")
                }
@@ -2305,7 +2309,7 @@ func (w *FixedLenByteArrayColumnChunkWriter) 
WriteBatchSpacedWithError(values []
                }
                repLevels = repLevels[:n]
                if repLevels[0] != 0 {
-                       panic("columnwriter: batch writing for V2 data pages 
must start at a row boundary")
+                       panic("columnwriter: row-aligned batch writing must 
start at a row boundary")
                }
        }
 
@@ -2325,10 +2329,10 @@ func (w *FixedLenByteArrayColumnChunkWriter) 
WriteBatchSpacedWithError(values []
                        }
                }
 
-               // V2 row-boundary alignment: a repeated row must not span a 
page boundary,
+               // Row-boundary alignment: a repeated row must not span an 
indexed page boundary,
                // so snap the batch onto the nearest row boundary (or keep a 
single wide row
                // whole). See alignBatchToRowBoundary in column_writer.go.
-               if isV2WithRep {
+               if requiresRowAlignment {
                        batch = alignBatchToRowBoundary(repLevels, levelOffset, 
batch)
                }
                if batch < 1 {
diff --git a/parquet/file/column_writer_types.gen.go.tmpl 
b/parquet/file/column_writer_types.gen.go.tmpl
index d23bdfa3..30c1e9e3 100644
--- a/parquet/file/column_writer_types.gen.go.tmpl
+++ b/parquet/file/column_writer_types.gen.go.tmpl
@@ -94,15 +94,16 @@ func (w *{{.Name}}ColumnChunkWriter) WriteBatch(values 
[]{{.name}}, defLevels, r
 
   batchSize := w.props.WriteBatchSize()
   maxDefLevel := w.descr.MaxDefinitionLevel()
-  isV2WithRep := w.props.DataPageVersion() != parquet.DataPageV1 &&
+  requiresRowAlignment := (w.props.DataPageVersion() != parquet.DataPageV1 ||
+    w.props.PageIndexEnabledFor(w.descr.Path())) &&
     repLevels != nil && w.descr.MaxRepetitionLevel() > 0
   levelOffset := int64(0)
 
-  // Repeated DataPageV2 writes align batches on row boundaries using repLevels
+  // Repeated writes that produce an offset index align batches on row 
boundaries
   // 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 requiresRowAlignment {
     if int64(len(repLevels)) < n {
       panic("columnwriter: not enough repetition levels for batch to write")
     }
@@ -111,7 +112,7 @@ func (w *{{.Name}}ColumnChunkWriter) WriteBatch(values 
[]{{.name}}, defLevels, r
     }
     repLevels = repLevels[:n]
     if repLevels[0] != 0 {
-      panic("columnwriter: batch writing for V2 data pages must start at a row 
boundary")
+      panic("columnwriter: row-aligned batch writing must start at a row 
boundary")
     }
   }
 
@@ -142,10 +143,10 @@ func (w *{{.Name}}ColumnChunkWriter) WriteBatch(values 
[]{{.name}}, defLevels, r
       }
     }
 
-    // V2 row-boundary alignment: a repeated row must not span a page boundary,
+    // Row-boundary alignment: a repeated row must not span an indexed page 
boundary,
     // so snap the batch onto the nearest row boundary (or keep a single wide 
row
     // whole). See alignBatchToRowBoundary in column_writer.go.
-    if isV2WithRep {
+    if requiresRowAlignment {
       batch = alignBatchToRowBoundary(repLevels, levelOffset, batch)
     }
     if batch < 1 {
@@ -231,13 +232,14 @@ func (w *{{.Name}}ColumnChunkWriter) 
WriteBatchSpacedWithError(values []{{.name}
   const maxSafeBatchDataSize int64 = 1 << 30 // 1GB
 
   batchSize := w.props.WriteBatchSize()
-  isV2WithRep := w.props.DataPageVersion() != parquet.DataPageV1 &&
+  requiresRowAlignment := (w.props.DataPageVersion() != parquet.DataPageV1 ||
+    w.props.PageIndexEnabledFor(w.descr.Path())) &&
     repLevels != nil && w.descr.MaxRepetitionLevel() > 0
   levelOffset := int64(0)
   n := int64(length)
 
   // Clamp repLevels to n; see WriteBatch. Mirrors columnWriter.doBatches.
-  if isV2WithRep {
+  if requiresRowAlignment {
     if int64(len(repLevels)) < n {
       panic("columnwriter: not enough repetition levels for batch to write")
     }
@@ -246,7 +248,7 @@ func (w *{{.Name}}ColumnChunkWriter) 
WriteBatchSpacedWithError(values []{{.name}
     }
     repLevels = repLevels[:n]
     if repLevels[0] != 0 {
-      panic("columnwriter: batch writing for V2 data pages must start at a row 
boundary")
+      panic("columnwriter: row-aligned batch writing must start at a row 
boundary")
     }
   }
 
@@ -270,10 +272,10 @@ func (w *{{.Name}}ColumnChunkWriter) 
WriteBatchSpacedWithError(values []{{.name}
       }
     }
 
-    // V2 row-boundary alignment: a repeated row must not span a page boundary,
+    // Row-boundary alignment: a repeated row must not span an indexed page 
boundary,
     // so snap the batch onto the nearest row boundary (or keep a single wide 
row
     // whole). See alignBatchToRowBoundary in column_writer.go.
-    if isV2WithRep {
+    if requiresRowAlignment {
       batch = alignBatchToRowBoundary(repLevels, levelOffset, batch)
     }
     if batch < 1 {

Reply via email to