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 1683b276 fix(parquet): wrap DELTA_BINARY_PACKED deltas at the physical 
type width (#1027)
1683b276 is described below

commit 1683b276c1dbad9d2ec306efae535d89d3d7dcdd
Author: Tom Frank <[email protected]>
AuthorDate: Mon Jul 27 18:56:04 2026 +0300

    fix(parquet): wrap DELTA_BINARY_PACKED deltas at the physical type width 
(#1027)
    
    ### Rationale for this change
    
    The Parquet spec requires DELTA_BINARY_PACKED delta arithmetic to wrap
    in two's complement at the physical type's width
    
([Encodings.md](https://github.com/apache/parquet-format/blob/master/Encodings.md#delta-encoding-delta_binary_packed--5)).
    The encoder computed INT32 deltas in `int64`, so wide-range int32
    columns (e.g. values spanning most of the int32 range) produced deltas
    outside the int32 range and miniblock bit-widths up to 34.
    parquet-cpp/pyarrow reject such pages as corrupt (`Unexpected end of
    stream: InitBlock EOF`), as does arrow-rs (`'min_delta' is too large`) —
    only arrow-go itself could read them back, because its decoder uses the
    same widened arithmetic. parquet-cpp explicitly declined to accept
    >32-bit INT32 miniblocks (apache/arrow#20374), so the writer must not
    emit them.
    
    ### What changes are included in this PR?
    
    `deltaBitPackEncoder[T].Put` now computes each delta at the physical
    type's width (`int64(in[idx] - T(enc.currentVal))`) so overflow wraps in
    two's complement as the spec requires, bounding miniblock bit-widths at
    32 for INT32. INT64 encoding is unchanged (the arithmetic was already at
    type width). The decoder needs no change: its int64 accumulation with a
    final cast to `T` decodes wrapped deltas correctly, and it still reads
    previously written out-of-spec files.
    
    ### Are these changes tested?
    
    Yes. New `TestDeltaBitPackedDeltaOverflow` covers alternating
    `MinInt32`/`MaxInt32`, hand-crafted pathological cases for int32, a
    golden-bytes case for wrapped-delta encoding, and an int64 no-regression
    case. A new `checkDeltaBitPackedWidths` helper parses the encoded stream
    and asserts every miniblock bit-width is within the physical type's
    width — round-trip tests alone cannot catch this, since arrow-go
    tolerates its own out-of-spec output. The test fails on the previous
    encoder and passes with the fix. Also verified end-to-end: a file
    written via `pqarrow` with a wide-range int32 DELTA_BINARY_PACKED column
    is now read correctly by pyarrow, where it previously failed with
    `InitBlock EOF`.
    
    ### Are there any user-facing changes?
    
    DELTA_BINARY_PACKED INT32 pages written for wide-value-range columns are
    now spec-compliant and readable by parquet-cpp/pyarrow, arrow-rs, and
    other strict readers. Files previously written with the out-of-spec
    widths remain readable by arrow-go.
---
 parquet/internal/encoding/delta_bit_packing.go |  10 +-
 parquet/internal/encoding/encoding_test.go     | 160 +++++++++++++++++++++++++
 2 files changed, 167 insertions(+), 3 deletions(-)

diff --git a/parquet/internal/encoding/delta_bit_packing.go 
b/parquet/internal/encoding/delta_bit_packing.go
index b74c27d0..0ee45423 100644
--- a/parquet/internal/encoding/delta_bit_packing.go
+++ b/parquet/internal/encoding/delta_bit_packing.go
@@ -402,9 +402,13 @@ func (enc *deltaBitPackEncoder[T]) Put(in []T) {
 
        enc.totalVals += uint64(len(in))
        for ; idx < len(in); idx++ {
-               val := int64(in[idx])
-               enc.deltas = append(enc.deltas, val-enc.currentVal)
-               enc.currentVal = val
+               // compute the delta at the physical type's width so that 
overflow wraps in
+               // two's complement, as the spec requires. Widening to int64 
first would produce
+               // deltas (and miniblock bit-widths) beyond the type's width 
for INT32 columns,
+               // which other readers (parquet-cpp, arrow-rs) reject as 
corrupt.
+               delta := int64(in[idx] - T(enc.currentVal))
+               enc.deltas = append(enc.deltas, delta)
+               enc.currentVal = int64(in[idx])
                if len(enc.deltas) == int(enc.blockSize) {
                        enc.flushBlock()
                }
diff --git a/parquet/internal/encoding/encoding_test.go 
b/parquet/internal/encoding/encoding_test.go
index 3ec9c4d2..8242e56e 100644
--- a/parquet/internal/encoding/encoding_test.go
+++ b/parquet/internal/encoding/encoding_test.go
@@ -18,9 +18,11 @@ package encoding_test
 
 import (
        "bufio"
+       "bytes"
        "encoding/binary"
        "fmt"
        "io"
+       "math"
        "os"
        "path"
        "reflect"
@@ -34,6 +36,7 @@ import (
        "github.com/apache/arrow-go/v18/parquet"
        "github.com/apache/arrow-go/v18/parquet/internal/encoding"
        "github.com/apache/arrow-go/v18/parquet/internal/testutils"
+       "github.com/apache/arrow-go/v18/parquet/internal/utils"
        "github.com/apache/arrow-go/v18/parquet/schema"
        "github.com/stretchr/testify/assert"
        "github.com/stretchr/testify/require"
@@ -698,6 +701,163 @@ func TestWriteDeltaBitPackedInt32(t *testing.T) {
        })
 }
 
+// checkDeltaBitPackedWidths walks an encoded DELTA_BINARY_PACKED stream and 
asserts
+// that no miniblock bit-width exceeds the physical type's width. The spec 
requires
+// delta arithmetic to wrap at the type width, so a compliant INT32 stream can 
never
+// need more than 32 bits per delta; parquet-cpp and arrow-rs reject wider 
miniblocks.
+func checkDeltaBitPackedWidths(t *testing.T, data []byte, maxWidth byte) {
+       rdr := utils.NewBitReader(bytes.NewReader(data))
+
+       blockSize, ok := rdr.GetVlqInt()
+       require.True(t, ok)
+       numMini, ok := rdr.GetVlqInt()
+       require.True(t, ok)
+       totalValues, ok := rdr.GetVlqInt()
+       require.True(t, ok)
+       _, ok = rdr.GetZigZagVlqInt() // first value
+       require.True(t, ok)
+
+       valsPerMini := blockSize / numMini
+       batch := make([]uint64, valsPerMini)
+       remaining := int64(totalValues) - 1
+       for remaining > 0 {
+               _, ok = rdr.GetZigZagVlqInt() // min delta
+               require.True(t, ok)
+
+               widths := make([]byte, numMini)
+               for i := range widths {
+                       w, err := rdr.ReadByte()
+                       require.NoError(t, err)
+                       widths[i] = w
+               }
+
+               for i := uint64(0); i < numMini && remaining > 0; i++ {
+                       assert.LessOrEqualf(t, widths[i], maxWidth, "miniblock 
%d bit-width exceeds physical type width", i)
+                       if widths[i] > 0 {
+                               // miniblocks are always padded to full length
+                               n, err := rdr.GetBatch(uint(widths[i]), batch)
+                               require.NoError(t, err)
+                               require.EqualValues(t, valsPerMini, n)
+                       }
+                       remaining -= min(int64(valsPerMini), remaining)
+               }
+       }
+}
+
+// Deltas between consecutive INT32 values can exceed the int32 range; the spec
+// requires them to wrap at the type width. Encoding them at int64 width 
instead
+// produces >32-bit miniblocks that only arrow-go itself can read back.
+func TestDeltaBitPackedDeltaOverflow(t *testing.T) {
+       t.Run("int32 alternating min max", func(t *testing.T) {
+               column := schema.NewColumn(schema.NewInt32Node("int32", 
parquet.Repetitions.Required, -1), 0, 0)
+               values := make([]int32, 1024)
+               for i := range values {
+                       if i%2 == 0 {
+                               values[i] = math.MinInt32
+                       } else {
+                               values[i] = math.MaxInt32
+                       }
+               }
+
+               enc := encoding.NewEncoder(parquet.Types.Int32, 
parquet.Encodings.DeltaBinaryPacked, false, column, memory.DefaultAllocator)
+               enc.(encoding.Int32Encoder).Put(values)
+               buf, _ := enc.FlushValues()
+               defer buf.Release()
+
+               checkDeltaBitPackedWidths(t, buf.Bytes(), 32)
+
+               dec := encoding.NewDecoder(parquet.Types.Int32, 
parquet.Encodings.DeltaBinaryPacked, column, memory.DefaultAllocator)
+               require.NoError(t, 
dec.(encoding.Int32Decoder).SetData(len(values), buf.Bytes()))
+               out := make([]int32, len(values))
+               _, err := dec.(encoding.Int32Decoder).Decode(out)
+               require.NoError(t, err)
+               assert.Equal(t, values, out)
+       })
+
+       t.Run("int32 boundary deltas", func(t *testing.T) {
+               column := schema.NewColumn(schema.NewInt32Node("int32", 
parquet.Repetitions.Required, -1), 0, 0)
+               // every boundary of the wrapped 32-bit delta domain; comments 
show the
+               // two's-complement-wrapped delta from the previous value
+               values := []int32{
+                       0,
+                       math.MinInt32,     // -2^31 (most negative wrapped 
delta)
+                       -1,                // +2^31-1 (most positive wrapped 
delta); with the previous delta this miniblock spans 2^32-1 -> width 32
+                       math.MaxInt32,     // true delta +2^31 wraps to -2^31
+                       -2,                // true delta -(2^31+1) wraps to 
+2^31-1
+                       math.MaxInt32 - 1, // true delta +2^31 wraps to -2^31
+                       math.MaxInt32 - 1, // 0 (zero delta alongside extreme 
deltas)
+                       math.MinInt32 + 1, // true delta -(2^32-3) wraps to +3
+                       0,                 // +2^31-1
+                       1,                 // +1
+                       math.MinInt32,     // true delta -(2^31+1) wraps to 
+2^31-1
+                       math.MaxInt32,     // true delta +(2^32-1) wraps to -1
+                       math.MinInt32,     // true delta -(2^32-1) wraps to +1
+               }
+
+               enc := encoding.NewEncoder(parquet.Types.Int32, 
parquet.Encodings.DeltaBinaryPacked, false, column, memory.DefaultAllocator)
+               enc.(encoding.Int32Encoder).Put(values)
+               buf, _ := enc.FlushValues()
+               defer buf.Release()
+
+               checkDeltaBitPackedWidths(t, buf.Bytes(), 32)
+
+               dec := encoding.NewDecoder(parquet.Types.Int32, 
parquet.Encodings.DeltaBinaryPacked, column, memory.DefaultAllocator)
+               require.NoError(t, 
dec.(encoding.Int32Decoder).SetData(len(values), buf.Bytes()))
+               out := make([]int32, len(values))
+               _, err := dec.(encoding.Int32Decoder).Decode(out)
+               require.NoError(t, err)
+               assert.Equal(t, values, out)
+       })
+
+       t.Run("int32 wrapped delta encoding", func(t *testing.T) {
+               // MaxInt32 -> MinInt32 wraps to a delta of +1, so the block 
encodes
+               // min_delta=1 with zero-width miniblocks.
+               column := schema.NewColumn(schema.NewInt32Node("int32", 
parquet.Repetitions.Required, -1), 0, 0)
+               values := []int32{math.MaxInt32, math.MinInt32}
+
+               enc := encoding.NewEncoder(parquet.Types.Int32, 
parquet.Encodings.DeltaBinaryPacked, false, column, memory.DefaultAllocator)
+               enc.(encoding.Int32Encoder).Put(values)
+               buf, _ := enc.FlushValues()
+               defer buf.Release()
+
+               expected := []byte{
+                       128, 1, // block size 128
+                       4,                      // 4 miniblocks per block
+                       2,                      // 2 values
+                       254, 255, 255, 255, 15, // first value zigzag(MaxInt32)
+                       2,          // min delta zigzag(+1)
+                       0, 0, 0, 0, // all-zero miniblock widths
+               }
+               assert.Equal(t, expected, buf.Bytes())
+       })
+
+       t.Run("int64 alternating min max", func(t *testing.T) {
+               column := schema.NewColumn(schema.NewInt64Node("int64", 
parquet.Repetitions.Required, -1), 0, 0)
+               values := make([]int64, 1024)
+               for i := range values {
+                       if i%2 == 0 {
+                               values[i] = math.MinInt64
+                       } else {
+                               values[i] = math.MaxInt64
+                       }
+               }
+
+               enc := encoding.NewEncoder(parquet.Types.Int64, 
parquet.Encodings.DeltaBinaryPacked, false, column, memory.DefaultAllocator)
+               enc.(encoding.Int64Encoder).Put(values)
+               buf, _ := enc.FlushValues()
+               defer buf.Release()
+
+               checkDeltaBitPackedWidths(t, buf.Bytes(), 64)
+
+               dec := encoding.NewDecoder(parquet.Types.Int64, 
parquet.Encodings.DeltaBinaryPacked, column, memory.DefaultAllocator)
+               require.NoError(t, 
dec.(encoding.Int64Decoder).SetData(len(values), buf.Bytes()))
+               out := make([]int64, len(values))
+               _, err := dec.(encoding.Int64Decoder).Decode(out)
+               require.NoError(t, err)
+               assert.Equal(t, values, out)
+       })
+}
+
 func TestWriteDeltaBitPackedInt64(t *testing.T) {
        column := schema.NewColumn(schema.NewInt64Node("int64", 
parquet.Repetitions.Required, -1), 0, 0)
 

Reply via email to