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 c23659c8 fix(parquet): stop BYTE_STREAM_SPLIT FLBA spaced decode
aliasing output (#1256)
c23659c8 is described below
commit c23659c8f0e4284acce0a978820abb4d9777cad8
Author: Matt Topol <[email protected]>
AuthorDate: Thu Sep 3 11:28:26 2026 -0400
fix(parquet): stop BYTE_STREAM_SPLIT FLBA spaced decode aliasing output
(#1256)
### Rationale for this change
Fixes #1255.
A `BYTE_STREAM_SPLIT` `FIXED_LEN_BYTE_ARRAY` column that contains nulls
**and** spans more than one data page decodes values shifted by one
position — silently, with no error returned.
`spacedExpand` moves values into their spaced positions with `copy` and
deliberately does not clean up the null slots:
```go
// because we technically don't care what is in the null slots we don't
actually have to clean
// up after ourselves ... Any data that happens to be left in the null
slots is fine
```
That reasoning holds for the scalar column types, whose buffers hold
values. But `ByteArray` / `FixedLenByteArray` buffers hold **slice
headers**, so `copy` leaves *duplicate headers* behind: after expansion
a null slot and a valid slot can reference the same backing array.
That is still harmless for a decoder that *replaces* the header, which
is what `PlainFixedLenByteArrayDecoder` does:
```go
out[idx] = pflba.data[:pflba.typeLen] // replaces
```
`ByteStreamSplitFixedLenByteArrayDecoder` instead writes **through** the
caller's existing slice:
```go
out[idx] = out[idx][:dec.typeLen] // reuses caller storage
...
out[element][stream] = data[encLoc] // writes through it
```
So once `flbaRecordReader` reused its value buffer for the next page,
two output slots shared one backing array and clobbered each other. This
explains the full shape of the bug: BYTE_STREAM_SPLIT only, nulls
required (to create the duplicates), and two or more pages required (the
first page creates the aliases, the second decodes into them).
### What changes are included in this PR?
- Add `spacedExpandSwap`, which swaps instead of copying so the buffer
remains a permutation of its original elements — no slot aliases
another, and every slot keeps its reusable capacity.
- Use it from `ByteStreamSplitFixedLenByteArrayDecoder.DecodeSpaced`.
- Leave `spacedExpand` itself untouched, so every other column type and
the non-spaced path are unaffected.
- Add a decoder-level regression test (two pages through one reused
buffer, widths 2/3/4/7/8/16) and a randomized differential test
asserting `spacedExpandSwap` places values in exactly the same slots as
`spacedExpand` while never leaving duplicates.
- Add a `pqarrow` round-trip test over a multi-page, nullable BSS FLBA
column — the integration-level case that was returning wrong data.
The decoder-level regression test fails at every width without the fix.
### Why swap rather than the simpler alternatives?
I measured two other approaches and rejected both:
**Making `spacedExpand` itself swap** is correct but replaces `memmove`
with element-wise swaps for *every* column type, which is far too
expensive on sparse-null runs:
```
SpacedExpandInt64/n65536/nullEvery0 4.457µ -> 81.265µ +1723%
SpacedExpandFLBA/n65536/nullEvery0 3.969µ -> 173.959µ +4283%
```
**`clear(out[:toRead])` in `DecodeSpaced`** is a one-liner, but discards
all reusable headers. On 4096 slots / 3511 values / width 16 that is
3511 allocations per `DecodeSpaced` on `main` today (it would drop to 1
once #1172 lands, but the fix should not depend on that).
Swapping keeps the reuse. Cost on the affected path only
(`BenchmarkBSSFLBADecodeSpaced`, 8192 slots):
```
w4/nullEvery100 41.64µ -> 60.55µ +45%
w4/nullEvery7 49.45µ -> 71.95µ +46%
w4/nullEvery2 95.83µ -> 89.59µ -7%
w16/nullEvery100 213.9µ -> 385.2µ +80%
w16/nullEvery7 244.7µ -> 251.3µ ~
w16/nullEvery2 154.3µ -> 160.4µ ~
geomean +25%
```
Steady-state allocations stay at 1–2 per call. A ~25% geomean cost on a
path that is currently returning **incorrect data** seemed clearly worth
it, and nothing outside BSS FLBA spaced decoding is touched.
### Are these changes tested?
Yes — new tests described above. `parquet/...` passes in full with
`PARQUET_TEST_DATA` supplied, and `-race` is clean on
`parquet/internal/encoding`.
### Are there any user-facing changes?
Yes: BYTE_STREAM_SPLIT FIXED_LEN_BYTE_ARRAY columns with nulls spanning
multiple data pages now decode correctly. Previously affected reads
returned silently incorrect values.
**This PR contains a "Critical Fix".** Reading an affected file produced
wrong values with no error, which could have been persisted or acted on
downstream without any indication of a problem.
---
parquet/internal/encoding/byte_stream_split.go | 7 +-
.../encoding/byte_stream_split_decode_test.go | 212 +++++++++++++++++++++
parquet/internal/encoding/decoder.go | 39 ++++
.../encoding/fixed_len_byte_array_decoder.go | 103 +++++++---
.../encoding/fixed_len_byte_array_decoder_test.go | 115 ++++++++++-
.../pqarrow/byte_stream_split_multipage_test.go | 113 +++++++++++
6 files changed, 551 insertions(+), 38 deletions(-)
diff --git a/parquet/internal/encoding/byte_stream_split.go
b/parquet/internal/encoding/byte_stream_split.go
index d5820093..62434aa2 100644
--- a/parquet/internal/encoding/byte_stream_split.go
+++ b/parquet/internal/encoding/byte_stream_split.go
@@ -107,15 +107,16 @@ func releaseBufferToPool(pooled *PooledBufferWriter) {
}
func validateByteStreamSplitPageData(typeLen, nvals int, data []byte) (int,
error) {
- if nvals*typeLen < len(data) {
+ encodedNvals, remainder := len(data)/typeLen, len(data)%typeLen
+ if encodedNvals > nvals || (encodedNvals == nvals && remainder != 0) {
return 0, fmt.Errorf("data size (%d) is too small for the
number of values in in BYTE_STREAM_SPLIT (%d)", len(data), nvals)
}
- if len(data)%typeLen != 0 {
+ if remainder != 0 {
return 0, fmt.Errorf("ByteStreamSplit data size %d not aligned
with byte_width: %d", len(data), typeLen)
}
- return len(data) / typeLen, nil
+ return encodedNvals, nil
}
type byteStreamSplitEncoder[T int32 | int64 | float32 | float64] struct {
diff --git a/parquet/internal/encoding/byte_stream_split_decode_test.go
b/parquet/internal/encoding/byte_stream_split_decode_test.go
index e6c72bdc..f1d8cfb2 100644
--- a/parquet/internal/encoding/byte_stream_split_decode_test.go
+++ b/parquet/internal/encoding/byte_stream_split_decode_test.go
@@ -19,11 +19,16 @@ package encoding
import (
"bytes"
"fmt"
+ "math/rand"
"testing"
"unsafe"
+ "github.com/apache/arrow-go/v18/arrow/bitutil"
+ "github.com/apache/arrow-go/v18/arrow/memory"
"github.com/apache/arrow-go/v18/internal/utils"
"github.com/apache/arrow-go/v18/parquet"
+ "github.com/apache/arrow-go/v18/parquet/schema"
+ "github.com/stretchr/testify/require"
)
func TestDecodeByteStreamSplitWidth4(t *testing.T) {
@@ -393,3 +398,210 @@ func BenchmarkDecodeByteStreamSplitBatchFLBAWidth8(b
*testing.B) {
})
}
}
+
+// TestByteStreamSplitFLBADecodeSpacedReusedBuffer guards the aliasing bug
where decoding
+// a second page into a buffer previously expanded by DecodeSpaced silently
corrupted
+// values: spacedExpand moves slice headers with copy, leaving duplicate
headers behind in
+// the null slots, and because this decoder writes through the caller's slices
rather than
+// replacing them, two output slots shared one backing array and clobbered
each other.
+func TestByteStreamSplitFLBADecodeSpacedReusedBuffer(t *testing.T) {
+ for _, width := range []int{2, 3, 4, 7, 8, 16} {
+ t.Run(fmt.Sprintf("width=%d", width), func(t *testing.T) {
+ // 5 slots, 2 nulls: slots 0, 2 and 4 are valid.
+ validBits := []byte{0b00010101}
+ const nullCount = 2
+
+ col :=
schema.NewColumn(schema.NewFixedLenByteArrayNode("v",
parquet.Repetitions.Optional, int32(width), -1), 1, 0)
+ dec := NewDecoder(parquet.Types.FixedLenByteArray,
parquet.Encodings.ByteStreamSplit,
+ col,
memory.DefaultAllocator).(FixedLenByteArrayDecoder)
+
+ // A single output buffer reused across both pages, as
the record reader does.
+ out := make([]parquet.FixedLenByteArray, 5)
+
+ for page, offset := range []byte{0, 100} {
+ values := make([]parquet.FixedLenByteArray, 3)
+ for i := range values {
+ values[i] =
make(parquet.FixedLenByteArray, width)
+ for j := range values[i] {
+ values[i][j] = offset +
byte(i*width+j)
+ }
+ }
+
+ data := make([]byte, len(values)*width)
+ for vi, v := range values {
+ for bi, b := range v {
+ data[bi*len(values)+vi] = b
+ }
+ }
+
+ require.NoError(t, dec.SetData(len(values),
data))
+ n, err := dec.DecodeSpaced(out, nullCount,
validBits, 0)
+ require.NoError(t, err)
+ require.Equal(t, len(out), n)
+
+ require.Equal(t, values[0], out[0], "page %d
slot 0", page)
+ require.Equal(t, values[1], out[2], "page %d
slot 2", page)
+ require.Equal(t, values[2], out[4], "page %d
slot 4", page)
+ }
+ })
+ }
+}
+
+// TestSpacedExpandSwapMatchesSpacedExpand checks that swapping places values
in exactly
+// the same slots as copying, and additionally never leaves duplicate entries
behind.
+func TestSpacedExpandSwapMatchesSpacedExpand(t *testing.T) {
+ rng := rand.New(rand.NewSource(42))
+ for iter := 0; iter < 5000; iter++ {
+ n := 1 + rng.Intn(200)
+ validBits := make([]byte, bitutil.BytesForBits(int64(n)))
+ nullCount, density := 0, rng.Float64()
+ for i := 0; i < n; i++ {
+ if rng.Float64() < density {
+ bitutil.ClearBit(validBits, i)
+ nullCount++
+ } else {
+ bitutil.SetBit(validBits, i)
+ }
+ }
+
+ // distinct sentinels so duplicates are detectable
+ copied, swapped := make([]int64, n), make([]int64, n)
+ for i := range copied {
+ copied[i], swapped[i] = int64(i+1), int64(i+1)
+ }
+
+ spacedExpand(copied, nullCount, validBits, 0)
+ spacedExpandSwap(swapped, nullCount, validBits, 0)
+
+ for i := 0; i < n; i++ {
+ if bitutil.BitIsSet(validBits, i) {
+ require.Equalf(t, copied[i], swapped[i],
+ "iter %d n=%d nulls=%d: valid slot %d
differs", iter, n, nullCount, i)
+ }
+ }
+
+ seen := make(map[int64]struct{}, n)
+ for _, v := range swapped {
+ seen[v] = struct{}{}
+ }
+ require.Lenf(t, seen, n,
+ "iter %d n=%d nulls=%d: swap left duplicate entries",
iter, n, nullCount)
+ }
+}
+
+// bssEncodeFLBA lays out values in BYTE_STREAM_SPLIT order: all byte 0s, then
all
+// byte 1s, and so on.
+func bssEncodeFLBA(values []parquet.FixedLenByteArray, width int) []byte {
+ data := make([]byte, len(values)*width)
+ for vi, v := range values {
+ for bi, b := range v {
+ data[bi*len(values)+vi] = b
+ }
+ }
+ return data
+}
+
+// TestByteStreamSplitFLBADoesNotWriteThroughForeignBuffers guards the second
half of the
+// aliasing hazard reported on GH-1255: this decoder reuses any output slice
with enough
+// capacity and writes through it, so if the previous page left slices that
point at
+// memory the decoder does not own, decoding corrupts that memory.
+//
+// Two producers leave such slices behind, and neither needs nulls to do it:
+//
+// - RLE_DICTIONARY assigns dict[idx] into every slot for that index, so a
repeated
+// index leaves several slots aliasing one dictionary-backed slice.
Writing through
+// them both clobbers a decoded value and corrupts the dictionary itself.
+// - PLAIN slices the page buffer directly, so every slot points into the
previous
+// page's data.
+func TestByteStreamSplitFLBADoesNotWriteThroughForeignBuffers(t *testing.T) {
+ for _, width := range []int{2, 3, 4, 7, 8, 16} {
+ t.Run(fmt.Sprintf("width=%d", width), func(t *testing.T) {
+ col :=
schema.NewColumn(schema.NewFixedLenByteArrayNode("v",
parquet.Repetitions.Required, int32(width), -1), 0, 0)
+
+ // The BSS page: three distinct values, decoded into a
reused buffer.
+ bssValues := make([]parquet.FixedLenByteArray, 3)
+ for i := range bssValues {
+ bssValues[i] = make(parquet.FixedLenByteArray,
width)
+ for j := range bssValues[i] {
+ bssValues[i][j] = 100 + byte(i*width+j)
+ }
+ }
+ bssData := bssEncodeFLBA(bssValues, width)
+
+ t.Run("after RLE_DICTIONARY", func(t *testing.T) {
+ // Dictionary of two entries, referenced by
indices [0, 0, 1] so that
+ // slots 0 and 1 alias the same
dictionary-backed slice.
+ dictBuf := make([]byte, 2*width)
+ for i := range dictBuf {
+ dictBuf[i] = byte(i + 1)
+ }
+ // RLE_DICTIONARY index data: bit width byte,
then a bit-packed run.
+ idxBuf := []byte{1, 0b00000011, 0b00000100}
+
+ plainDict :=
NewDecoder(parquet.Types.FixedLenByteArray, parquet.Encodings.Plain,
+ col, memory.DefaultAllocator)
+ require.NoError(t, plainDict.SetData(2,
dictBuf))
+
+ dictDec :=
NewDictDecoder(parquet.Types.FixedLenByteArray, col,
memory.DefaultAllocator).(*DictFixedLenByteArrayDecoder)
+ dictDec.SetDict(plainDict)
+ require.NoError(t, dictDec.SetData(3, idxBuf))
+
+ out := make([]parquet.FixedLenByteArray, 3)
+ n, err := dictDec.Decode(out)
+ require.NoError(t, err)
+ require.Equal(t, 3, n)
+
+ dictBefore := bytes.Clone(dictBuf)
+
+ bssDec :=
NewDecoder(parquet.Types.FixedLenByteArray, parquet.Encodings.ByteStreamSplit,
+ col,
memory.DefaultAllocator).(FixedLenByteArrayDecoder)
+ require.NoError(t,
bssDec.SetData(len(bssValues), bssData))
+ n, err = bssDec.Decode(out)
+ require.NoError(t, err)
+ require.Equal(t, len(bssValues), n)
+
+ for i, want := range bssValues {
+ require.Equalf(t, want, out[i], "slot
%d decoded incorrectly", i)
+ }
+ require.Equal(t, dictBefore, dictBuf, "decoding
wrote through into the dictionary page buffer")
+ })
+
+ t.Run("after PLAIN", func(t *testing.T) {
+ plainValues :=
make([]parquet.FixedLenByteArray, 3)
+ for i := range plainValues {
+ plainValues[i] =
make(parquet.FixedLenByteArray, width)
+ for j := range plainValues[i] {
+ plainValues[i][j] =
byte(i*width + j)
+ }
+ }
+ plainData := make([]byte, 0,
len(plainValues)*width)
+ for _, v := range plainValues {
+ plainData = append(plainData, v...)
+ }
+
+ plainDec :=
NewDecoder(parquet.Types.FixedLenByteArray, parquet.Encodings.Plain,
+ col,
memory.DefaultAllocator).(FixedLenByteArrayDecoder)
+ require.NoError(t,
plainDec.SetData(len(plainValues), plainData))
+
+ out := make([]parquet.FixedLenByteArray, 3)
+ n, err := plainDec.Decode(out)
+ require.NoError(t, err)
+ require.Equal(t, len(plainValues), n)
+
+ plainBefore := bytes.Clone(plainData)
+
+ bssDec :=
NewDecoder(parquet.Types.FixedLenByteArray, parquet.Encodings.ByteStreamSplit,
+ col,
memory.DefaultAllocator).(FixedLenByteArrayDecoder)
+ require.NoError(t,
bssDec.SetData(len(bssValues), bssData))
+ n, err = bssDec.Decode(out)
+ require.NoError(t, err)
+ require.Equal(t, len(bssValues), n)
+
+ for i, want := range bssValues {
+ require.Equalf(t, want, out[i], "slot
%d decoded incorrectly", i)
+ }
+ require.Equal(t, plainBefore, plainData,
"decoding wrote through into the PLAIN page buffer")
+ })
+ })
+ }
+}
diff --git a/parquet/internal/encoding/decoder.go
b/parquet/internal/encoding/decoder.go
index e97b5a13..a6f7931d 100644
--- a/parquet/internal/encoding/decoder.go
+++ b/parquet/internal/encoding/decoder.go
@@ -229,6 +229,45 @@ func (d *dictDecoder[T]) DecodeIndicesSpaced(numValues,
nullCount int, validBits
return n, nil
}
+// spacedExpandSwap is spacedExpand for reusable slices whose entries may
later be
+// written through. spacedExpand moves values with copy and leaves the null
slots alone,
+// which for the slice-header column types (ByteArray / FixedLenByteArray)
leaves
+// duplicate headers behind: a null slot and a valid slot end up referencing
the same
+// backing array. A later decoder that writes through those entries would then
clobber
+// one value when the buffer is reused for another page.
+//
+// Swapping instead of copying keeps the buffer a permutation of its original
elements,
+// so no slot aliases another and every slot keeps its reusable capacity.
+func spacedExpandSwap[T parquet.ColumnTypes](buffer []T, nullCount int,
validBits []byte, validBitsOffset int64) int {
+ numValues := len(buffer)
+
+ idxDecode := int64(numValues - nullCount)
+ if idxDecode == 0 {
+ return numValues
+ }
+
+ rdr := bitutils.NewReverseSetBitRunReader(validBits, validBitsOffset,
int64(numValues))
+ for {
+ run := rdr.NextRun()
+ if run.Length == 0 {
+ break
+ }
+
+ idxDecode -= run.Length
+ // Once the decoded prefix is already aligned every remaining
swap is a
+ // self-swap, so there is nothing left to do. Mirrors
spacedExpand.
+ if idxDecode == run.Pos {
+ return numValues
+ }
+ for k := run.Length - 1; k >= 0; k-- {
+ dst, src := run.Pos+k, idxDecode+k
+ buffer[dst], buffer[src] = buffer[src], buffer[dst]
+ }
+ }
+
+ return numValues
+}
+
// spacedExpand is used to take a slice of data and utilize the bitmap
provided to fill in nulls into the
// correct slots according to the bitmap in order to produce a fully expanded
result slice with nulls
// in the correct slots.
diff --git a/parquet/internal/encoding/fixed_len_byte_array_decoder.go
b/parquet/internal/encoding/fixed_len_byte_array_decoder.go
index 080e9a06..e18223ae 100644
--- a/parquet/internal/encoding/fixed_len_byte_array_decoder.go
+++ b/parquet/internal/encoding/fixed_len_byte_array_decoder.go
@@ -20,6 +20,7 @@ import (
"errors"
"fmt"
"math"
+ "unsafe"
"github.com/apache/arrow-go/v18/internal/utils"
"github.com/apache/arrow-go/v18/parquet"
@@ -125,7 +126,9 @@ func (pflba *PlainFixedLenByteArrayDecoder)
DecodeSpaced(out []parquet.FixedLenB
return valuesRead, errors.New("parquet: number of values /
definitions levels read did not match")
}
- return spacedExpand(out, nullCount, validBits, validBitsOffset), nil
+ // Keep every output slot independent because a later decoder may write
through
+ // these slices when this buffer is reused across pages.
+ return spacedExpandSwap(out, nullCount, validBits, validBitsOffset), nil
}
// ByteStreamSplitFixedLenByteArrayDecoder is a decoder for
BYTE_STREAM_SPLIT-encoded
@@ -133,6 +136,10 @@ func (pflba *PlainFixedLenByteArrayDecoder)
DecodeSpaced(out []parquet.FixedLenB
type ByteStreamSplitFixedLenByteArrayDecoder struct {
decoder
stride int
+
+ // storage is the most recent block this decoder allocated for output
values.
+ // Decode may only write through an output slice backed by this block;
see owns.
+ storage []byte
}
func (dec *ByteStreamSplitFixedLenByteArrayDecoder) Type() parquet.Type {
@@ -140,18 +147,16 @@ func (dec *ByteStreamSplitFixedLenByteArrayDecoder)
Type() parquet.Type {
}
func (dec *ByteStreamSplitFixedLenByteArrayDecoder) SetData(nvals int, data
[]byte) error {
- if nvals*dec.typeLen < len(data) {
- return fmt.Errorf("data size (%d) is too small for the number
of values in in BYTE_STREAM_SPLIT (%d)", len(data), nvals)
+ encodedNvals, err := validateByteStreamSplitPageData(dec.typeLen,
nvals, data)
+ if err != nil {
+ return err
}
-
- if len(data)%dec.typeLen != 0 {
- return fmt.Errorf("ByteStreamSplit data size %d not aligned
with type %s and byte_width: %d", len(data), dec.Type(), dec.typeLen)
+ if dec.descr != nil && dec.descr.MaxDefinitionLevel() == 0 &&
encodedNvals != nvals {
+ return fmt.Errorf("BYTE_STREAM_SPLIT data contains %d values,
expected %d", encodedNvals, nvals)
}
- nvals = len(data) / dec.typeLen
- dec.stride = nvals
-
- return dec.decoder.SetData(nvals, data)
+ dec.stride = encodedNvals
+ return dec.decoder.SetData(encodedNvals, data)
}
func (dec *ByteStreamSplitFixedLenByteArrayDecoder) Discard(n int) (int,
error) {
@@ -174,13 +179,7 @@ func (dec *ByteStreamSplitFixedLenByteArrayDecoder)
Decode(out []parquet.FixedLe
}
out = out[:toRead]
- for idx := range out {
- if cap(out[idx]) < dec.typeLen {
- dec.prepareOutput(out[idx:])
- break
- }
- out[idx] = out[idx][:dec.typeLen]
- }
+ dec.prepareOutput(out)
switch dec.typeLen {
case 2:
@@ -198,29 +197,73 @@ func (dec *ByteStreamSplitFixedLenByteArrayDecoder)
Decode(out []parquet.FixedLe
return toRead, nil
}
-// prepareOutput allocates storage for the entries in out that do not have
enough
-// capacity, while continuing to reuse the entries that do.
-func (dec *ByteStreamSplitFixedLenByteArrayDecoder) prepareOutput(out
[]parquet.FixedLenByteArray) {
- missing := 0
+// owns reports whether every entry of out is backed by the block this decoder
most
+// recently allocated, and so may be written through.
+//
+// The check matters because this decoder decodes in place: it writes bytes
through the
+// slice headers the caller hands it. Reusing a header whose memory belongs to
something
+// else corrupts that memory. Two earlier decoders leave such headers in a
shared value
+// buffer, neither of which requires nulls to do so:
+//
+// - RLE_DICTIONARY assigns dict[idx] into every slot holding that index, so
repeated
+// indices leave several slots aliasing one dictionary-backed slice.
Writing through
+// them clobbers a decoded value and corrupts the dictionary itself.
+// - PLAIN slices the page buffer directly, so every slot points into that
page's data.
+//
+// Capacity alone cannot distinguish these from our own storage, so we compare
against
+// the bounds of the block we allocated.
+func (dec *ByteStreamSplitFixedLenByteArrayDecoder) owns(out
[]parquet.FixedLenByteArray) bool {
+ if len(dec.storage) == 0 {
+ return false
+ }
+
+ base := uintptr(unsafe.Pointer(unsafe.SliceData(dec.storage)))
+ end := base + uintptr(len(dec.storage))
for idx := range out {
if cap(out[idx]) < dec.typeLen {
- missing++
+ return false
+ }
+ p := uintptr(unsafe.Pointer(unsafe.SliceData(out[idx])))
+ if p < base || p+uintptr(dec.typeLen) > end {
+ return false
}
}
+ return true
+}
- storage := make([]byte, missing*dec.typeLen)
- for idx := range out {
- if cap(out[idx]) < dec.typeLen {
- out[idx] = storage[:dec.typeLen:dec.typeLen]
- storage = storage[dec.typeLen:]
- } else {
+// prepareOutput points every entry of out at storage this decoder owns, so
that decoding
+// in place cannot write through into memory belonging to a previous page's
decoder.
+//
+// When out already sits entirely within our own block the headers are reused
as-is and
+// nothing is allocated, which keeps repeated decodes into the same buffer
allocation
+// free. Otherwise a single block is allocated for the whole window. Earlier
windows keep
+// pointing at the blocks they were given, so callers that decode into
successive windows
+// of one buffer (as the record reader does) keep their previously decoded
values.
+func (dec *ByteStreamSplitFixedLenByteArrayDecoder) prepareOutput(out
[]parquet.FixedLenByteArray) {
+ if dec.owns(out) {
+ for idx := range out {
out[idx] = out[idx][:dec.typeLen]
}
+ return
+ }
+
+ storage := make([]byte, len(out)*dec.typeLen)
+ dec.storage = storage
+ for idx := range out {
+ out[idx] = storage[:dec.typeLen:dec.typeLen]
+ storage = storage[dec.typeLen:]
}
}
func (dec *ByteStreamSplitFixedLenByteArrayDecoder) DecodeSpaced(out
[]parquet.FixedLenByteArray, nullCount int, validBits []byte, validBitsOffset
int64) (int, error) {
toRead := len(out) - nullCount
+
+ // Back every slot, the null slots included, before decoding. The
expansion below
+ // permutes headers across the whole window, so preparing only the
decoded prefix
+ // would let headers we do not own migrate into it and force the next
page to
+ // reallocate. Preparing the window once keeps repeated decodes
allocation free.
+ dec.prepareOutput(out)
+
valuesRead, err := dec.Decode(out[:toRead])
if err != nil {
return valuesRead, err
@@ -229,5 +272,7 @@ func (dec *ByteStreamSplitFixedLenByteArrayDecoder)
DecodeSpaced(out []parquet.F
return valuesRead, errors.New("parquet: number of values /
definitions levels read did not match")
}
- return spacedExpand(out, nullCount, validBits, validBitsOffset), nil
+ // This decoder writes through the caller's slices, so it must not
leave aliased
+ // headers behind for the next page; see spacedExpandSwap.
+ return spacedExpandSwap(out, nullCount, validBits, validBitsOffset), nil
}
diff --git a/parquet/internal/encoding/fixed_len_byte_array_decoder_test.go
b/parquet/internal/encoding/fixed_len_byte_array_decoder_test.go
index 732d61e7..8c07a55a 100644
--- a/parquet/internal/encoding/fixed_len_byte_array_decoder_test.go
+++ b/parquet/internal/encoding/fixed_len_byte_array_decoder_test.go
@@ -27,6 +27,37 @@ import (
"github.com/stretchr/testify/require"
)
+func TestByteStreamSplitFixedLenByteArrayDecoderRejectsTruncatedRequiredData(t
*testing.T) {
+ const width = 4
+ node := schema.NewFixedLenByteArrayNode("value",
parquet.Repetitions.Required, width, -1)
+ column := schema.NewColumn(node, 0, 0)
+ decoder := NewDecoder(parquet.Types.FixedLenByteArray,
parquet.Encodings.ByteStreamSplit, column, memory.DefaultAllocator)
+
+ err := decoder.SetData(3, make([]byte, 2*width))
+ require.EqualError(t, err, "BYTE_STREAM_SPLIT data contains 2 values,
expected 3")
+ require.Zero(t, decoder.ValuesLeft())
+}
+
+func TestByteStreamSplitFixedLenByteArrayDecoderAllowsNulls(t *testing.T) {
+ const width = 4
+ values := makeFixedLenByteArrayValues(2, width, 0)
+ node := schema.NewFixedLenByteArrayNode("value",
parquet.Repetitions.Optional, width, -1)
+ column := schema.NewColumn(node, 1, 0)
+ decoder := NewDecoder(parquet.Types.FixedLenByteArray,
parquet.Encodings.ByteStreamSplit, column,
memory.DefaultAllocator).(FixedLenByteArrayDecoder)
+
+ require.NoError(t, decoder.SetData(3,
encodeByteStreamSplitFixedLenByteArray(values, width)))
+ out := make([]parquet.FixedLenByteArray, len(values))
+ decoded, err := decoder.Decode(out)
+ require.NoError(t, err)
+ require.Equal(t, len(values), decoded)
+ require.Equal(t, values, out)
+
+ // An optional page can legitimately contain no physical values even
when its
+ // logical count would overflow when multiplied by the byte width.
+ require.NoError(t, decoder.SetData(int(^uint(0)>>1), nil))
+ require.Zero(t, decoder.ValuesLeft())
+}
+
func TestByteStreamSplitFixedLenByteArrayDecoderContiguousOutput(t *testing.T)
{
for _, width := range []int{2, 4, 8, 16, 32} {
t.Run(fmt.Sprintf("width=%d", width), func(t *testing.T) {
@@ -51,15 +82,24 @@ func
TestByteStreamSplitFixedLenByteArrayDecoderContiguousOutput(t *testing.T) {
}
}
-func TestByteStreamSplitFixedLenByteArrayDecoderReusesProvidedOutput(t
*testing.T) {
+//
TestByteStreamSplitFixedLenByteArrayDecoderDoesNotWriteThroughProvidedOutput
pins the
+// narrowed reuse contract introduced for GH-1255.
+//
+// This decoder writes bytes through the slice headers it is handed, so it may
only reuse
+// a header backed by storage it allocated itself. Output buffers are shared
across pages
+// and encodings, and RLE_DICTIONARY and PLAIN both leave headers pointing at
memory they
+// own (dictionary entries and the page buffer respectively). Reusing those on
capacity
+// alone corrupted that memory, so capacity is no longer sufficient to claim a
header.
+func
TestByteStreamSplitFixedLenByteArrayDecoderDoesNotWriteThroughProvidedOutput(t
*testing.T) {
const width = 16
values := makeFixedLenByteArrayValues(4, width, 0)
decoder := newByteStreamSplitFixedLenByteArrayDecoder(t, width, values)
+ caller := make([]byte, width)
out := []parquet.FixedLenByteArray{
make([]byte, 0, width+4),
nil,
- make([]byte, width),
+ caller,
nil,
}
firstPtr := unsafe.Pointer(unsafe.SliceData(out[0]))
@@ -69,10 +109,44 @@ func
TestByteStreamSplitFixedLenByteArrayDecoderReusesProvidedOutput(t *testing.
require.NoError(t, err)
require.Equal(t, len(values), decoded)
require.Equal(t, values, out)
- require.Equal(t, firstPtr, unsafe.Pointer(unsafe.SliceData(out[0])))
- require.Equal(t, thirdPtr, unsafe.Pointer(unsafe.SliceData(out[2])))
- require.Equal(t, uintptr(width),
-
uintptr(unsafe.Pointer(&out[3][0]))-uintptr(unsafe.Pointer(&out[1][0])))
+
+ // The caller's buffers are re-pointed rather than written through, and
are left
+ // untouched.
+ require.NotEqual(t, firstPtr, unsafe.Pointer(unsafe.SliceData(out[0])))
+ require.NotEqual(t, thirdPtr, unsafe.Pointer(unsafe.SliceData(out[2])))
+ require.Equal(t, make([]byte, width), caller, "decoding wrote through a
caller buffer")
+
+ // Every slot now comes from one contiguous block the decoder owns.
+ for idx := 1; idx < len(out); idx++ {
+ previous := uintptr(unsafe.Pointer(&out[idx-1][0]))
+ current := uintptr(unsafe.Pointer(&out[idx][0]))
+ require.Equal(t, uintptr(width), current-previous)
+ }
+}
+
+// TestByteStreamSplitFixedLenByteArrayDecoderReusesOwnStorage checks that the
narrowed
+// contract still keeps repeated decodes into the same buffer allocation free,
which is
+// the case GH-1172 optimized.
+func TestByteStreamSplitFixedLenByteArrayDecoderReusesOwnStorage(t *testing.T)
{
+ const width = 16
+ values := makeFixedLenByteArrayValues(4, width, 0)
+ data := encodeByteStreamSplitFixedLenByteArray(values, width)
+ decoder := newByteStreamSplitFixedLenByteArrayDecoder(t, width, values)
+
+ // First decode hands out the decoder's own block.
+ out := make([]parquet.FixedLenByteArray, len(values))
+ _, err := decoder.Decode(out)
+ require.NoError(t, err)
+ require.Equal(t, values, out)
+
+ // Subsequent decodes into that same window recognize the block as
their own.
+ allocs := testing.AllocsPerRun(100, func() {
+ require.NoError(t, decoder.SetData(len(values), data))
+ _, err := decoder.Decode(out)
+ require.NoError(t, err)
+ })
+ require.Zero(t, allocs)
+ require.Equal(t, values, out)
}
func TestByteStreamSplitFixedLenByteArrayDecoderMixedOutputAllocations(t
*testing.T) {
@@ -154,6 +228,35 @@ func
TestByteStreamSplitFixedLenByteArrayDecoderSpacedOutput(t *testing.T) {
require.Equal(t, uintptr(width), third-second)
}
+func TestFixedLenByteArrayDecoderSpacedOutputAcrossEncodings(t *testing.T) {
+ const width = 4
+ validBits := []byte{0b00010101}
+ firstValues := makeFixedLenByteArrayValues(3, width, 0)
+ secondValues := makeFixedLenByteArrayValues(3, width, 100)
+
+ node := schema.NewFixedLenByteArrayNode("value",
parquet.Repetitions.Required, width, -1)
+ column := schema.NewColumn(node, 0, 0)
+ plain := NewDecoder(parquet.Types.FixedLenByteArray,
parquet.Encodings.Plain, column,
memory.DefaultAllocator).(FixedLenByteArrayDecoder)
+ plainData := make([]byte, 0, len(firstValues)*width)
+ for _, value := range firstValues {
+ plainData = append(plainData, value...)
+ }
+ require.NoError(t, plain.SetData(len(firstValues), plainData))
+
+ out := make([]parquet.FixedLenByteArray, 5)
+ decoded, err := plain.DecodeSpaced(out, 2, validBits, 0)
+ require.NoError(t, err)
+ require.Equal(t, len(out), decoded)
+
+ byteStreamSplit := newByteStreamSplitFixedLenByteArrayDecoder(t, width,
secondValues)
+ decoded, err = byteStreamSplit.DecodeSpaced(out, 2, validBits, 0)
+ require.NoError(t, err)
+ require.Equal(t, len(out), decoded)
+ require.Equal(t, secondValues[0], out[0])
+ require.Equal(t, secondValues[1], out[2])
+ require.Equal(t, secondValues[2], out[4])
+}
+
func newByteStreamSplitFixedLenByteArrayDecoder(t *testing.T, width int,
values []parquet.FixedLenByteArray) FixedLenByteArrayDecoder {
t.Helper()
diff --git a/parquet/pqarrow/byte_stream_split_multipage_test.go
b/parquet/pqarrow/byte_stream_split_multipage_test.go
new file mode 100644
index 00000000..b1886ea7
--- /dev/null
+++ b/parquet/pqarrow/byte_stream_split_multipage_test.go
@@ -0,0 +1,113 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements. See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership. The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License. You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package pqarrow_test
+
+import (
+ "bytes"
+ "context"
+ "fmt"
+ "testing"
+
+ "github.com/apache/arrow-go/v18/arrow"
+ "github.com/apache/arrow-go/v18/arrow/array"
+ "github.com/apache/arrow-go/v18/arrow/memory"
+ "github.com/apache/arrow-go/v18/parquet"
+ "github.com/apache/arrow-go/v18/parquet/file"
+ "github.com/apache/arrow-go/v18/parquet/pqarrow"
+ "github.com/stretchr/testify/require"
+)
+
+// TestByteStreamSplitFLBANullsMultiPage covers a BYTE_STREAM_SPLIT
FIXED_LEN_BYTE_ARRAY
+// column whose chunk spans several data pages and contains nulls. The record
reader
+// reuses one value buffer across pages, and DecodeSpaced previously left
aliased slice
+// headers in it, so values decoded from the second page onwards came back
shifted.
+func TestByteStreamSplitFLBANullsMultiPage(t *testing.T) {
+ for _, width := range []int{4, 17} {
+ t.Run(fmt.Sprintf("width=%d", width), func(t *testing.T) {
+ const nrows = 5000
+ mem :=
memory.NewCheckedAllocator(memory.DefaultAllocator)
+ defer mem.AssertSize(t, 0)
+
+ dt := &arrow.FixedSizeBinaryType{ByteWidth: width}
+ sc := arrow.NewSchema([]arrow.Field{{Name: "v", Type:
dt, Nullable: true}}, nil)
+
+ bldr := array.NewFixedSizeBinaryBuilder(mem, dt)
+ defer bldr.Release()
+
+ expected := make([][]byte, nrows)
+ for i := range expected {
+ if i%7 == 3 {
+ bldr.AppendNull()
+ continue
+ }
+ v := make([]byte, width)
+ for j := range v {
+ v[j] = byte(i*width + j)
+ }
+ bldr.Append(v)
+ expected[i] = v
+ }
+
+ arr := bldr.NewArray()
+ defer arr.Release()
+ rec := array.NewRecordBatch(sc, []arrow.Array{arr},
nrows)
+ defer rec.Release()
+
+ var buf bytes.Buffer
+ props := parquet.NewWriterProperties(
+ parquet.WithAllocator(mem),
+
parquet.WithEncoding(parquet.Encodings.ByteStreamSplit),
+ parquet.WithDictionaryDefault(false),
+ // small pages so the column chunk spans more
than one data page
+ parquet.WithDataPageSize(512),
+ parquet.WithBatchSize(128),
+ )
+ w, err := pqarrow.NewFileWriter(sc, &buf, props,
pqarrow.DefaultWriterProps())
+ require.NoError(t, err)
+ require.NoError(t, w.Write(rec))
+ require.NoError(t, w.Close())
+
+ rdr, err :=
file.NewParquetReader(bytes.NewReader(buf.Bytes()),
+
file.WithReadProps(parquet.NewReaderProperties(mem)))
+ require.NoError(t, err)
+ defer rdr.Close()
+
+ fr, err := pqarrow.NewFileReader(rdr,
pqarrow.ArrowReadProperties{BatchSize: 137}, mem)
+ require.NoError(t, err)
+ tbl, err := fr.ReadTable(context.Background())
+ require.NoError(t, err)
+ defer tbl.Release()
+
+ require.EqualValues(t, nrows, tbl.NumRows())
+
+ row := 0
+ for _, chunk := range tbl.Column(0).Data().Chunks() {
+ fsb := chunk.(*array.FixedSizeBinary)
+ for i := 0; i < fsb.Len(); i++ {
+ if expected[row] == nil {
+ require.Truef(t, fsb.IsNull(i),
"row %d should be null", row)
+ } else {
+ require.Falsef(t,
fsb.IsNull(i), "row %d should be valid", row)
+ require.Equalf(t,
expected[row], fsb.Value(i), "row %d", row)
+ }
+ row++
+ }
+ }
+ require.Equal(t, nrows, row)
+ })
+ }
+}