Skip to content

parquet: BYTE_STREAM_SPLIT FIXED_LEN_BYTE_ARRAY with nulls across multiple data pages decodes values shifted by one #1255

Description

@zeroshade

Describe the bug, including details regarding any error messages, version, and platform.

A FIXED_LEN_BYTE_ARRAY column encoded with BYTE_STREAM_SPLIT decodes values shifted by one position when the column chunk contains both nulls and more than one data page. The data is silently wrong — no error is returned.

Found on main (f85b0b25 / 477eb633), linux/amd64, Go 1.24. This is not a recent regression; it reproduces on older commits as well. Surfaced while reviewing #1172, which is unrelated and does not cause it.

Isolation, already narrowed:

  • Encoding-specific — PLAIN passes on byte-identical data and writer configuration; only BYTE_STREAM_SPLIT fails.
  • Requires both conditions:
    • a single large data page (1 MiB) with nulls passes
    • multiple data pages without nulls passes
    • multiple data pages with nulls fails
  • Reproduced at widths 2, 3, 4, 7, 8, 16, 17, and 32 — not width-specific.
  • First mismatch appears partway through, not at the start (e.g. around index 259 for width 4).

Example failure at width 4:

mismatch at idx 259
expected: []byte{0x0c, 0x0d, 0x0e, 0x0f}
actual  : []byte{0x10, 0x11, 0x12, 0x13}

0x10 0x11 0x12 0x13 is the value belonging to index 260, i.e. the stream is off by exactly one value from that point on.

Not root-caused. ByteStreamSplitFixedLenByteArrayDecoder.SetData recomputing nvals = len(data)/typeLen and setting stride looks correct on inspection, so the fault is likely elsewhere — either per-page decoder reset in the column reader or the encoder's per-page flush. I have not determined whether the file is written incorrectly or read incorrectly.

Note on existing coverage: the spaced-decoding unit tests use a single SetData followed by one DecodeSpaced, so they exercise only the single-page case and pass. A fix should come with a multi-page + nulls integration test.

Reproducer

Drop into parquet/pqarrow/ as bss_flba_repro_test.go and run
go test ./parquet/pqarrow/ -run TestBSSFLBANullsMultiPage -v.

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"
)

func TestBSSFLBANullsMultiPage(t *testing.T) {
	encs := map[string]parquet.Encoding{
		"BSS":   parquet.Encodings.ByteStreamSplit,
		"PLAIN": parquet.Encodings.Plain,
	}
	for encName, enc := range encs {
		for _, pageSize := range []int64{512, 1 << 20} { // small pages vs one big page
			for _, width := range []int{4, 32} {
				for _, nulls := range []bool{false, true} {
					t.Run(fmt.Sprintf("%s/page%d/w%d/nulls=%v", encName, pageSize, width, nulls), func(t *testing.T) {
						mem := memory.DefaultAllocator
						const n = 5000

						dt := &arrow.FixedSizeBinaryType{ByteWidth: width}
						sc := arrow.NewSchema([]arrow.Field{{Name: "v", Type: dt, Nullable: nulls}}, nil)

						bldr := array.NewFixedSizeBinaryBuilder(mem, dt)
						defer bldr.Release()
						want := make([][]byte, 0, n)
						for i := 0; i < n; i++ {
							if nulls && i%7 == 3 {
								bldr.AppendNull()
								want = append(want, nil)
								continue
							}
							v := make([]byte, width)
							for j := range v {
								v[j] = byte(i*width + j)
							}
							bldr.Append(v)
							want = append(want, v)
						}
						arr := bldr.NewArray()
						defer arr.Release()
						rec := array.NewRecord(sc, []arrow.Array{arr}, int64(n))
						defer rec.Release()

						var buf bytes.Buffer
						props := parquet.NewWriterProperties(
							parquet.WithEncoding(enc),
							parquet.WithDictionaryDefault(false),
							parquet.WithDataPageSize(pageSize),
							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()))
						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, n, tbl.NumRows())
						idx := 0
						for _, c := range tbl.Column(0).Data().Chunks() {
							fsb := c.(*array.FixedSizeBinary)
							for i := 0; i < fsb.Len(); i++ {
								if want[idx] == nil {
									require.True(t, fsb.IsNull(i), "idx %d should be null", idx)
								} else {
									require.False(t, fsb.IsNull(i), "idx %d should be valid", idx)
									require.Equal(t, want[idx], fsb.Value(i), "mismatch at idx %d", idx)
								}
								idx++
							}
						}
						require.Equal(t, n, idx)
					})
				}
			}
		}
	}
}

Observed result — only the BSS multi-page-with-nulls combinations fail:

--- PASS: TestBSSFLBANullsMultiPage/BSS/page512/w4/nulls=false
--- FAIL: TestBSSFLBANullsMultiPage/BSS/page512/w4/nulls=true
--- PASS: TestBSSFLBANullsMultiPage/BSS/page512/w32/nulls=false
--- FAIL: TestBSSFLBANullsMultiPage/BSS/page512/w32/nulls=true
--- PASS: TestBSSFLBANullsMultiPage/BSS/page1048576/w4/nulls=true
--- PASS: TestBSSFLBANullsMultiPage/BSS/page1048576/w32/nulls=true
--- PASS: TestBSSFLBANullsMultiPage/PLAIN/... (all)

Component(s)

Parquet

Metadata

Metadata

Assignees

No one assigned

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions