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 53a747fe feat(parquet): opt-in streaming reads for large data pages
(#880)
53a747fe is described below
commit 53a747fe62f61d6be986dfaa9cf8d1bc90f750dc
Author: Ruihao Chen <[email protected]>
AuthorDate: Tue Jul 14 03:09:38 2026 +0800
feat(parquet): opt-in streaming reads for large data pages (#880)
### Rationale for this change
Addresses #865. Today the reader materializes each full uncompressed data
page before decoding, so peak memory scales with the page's uncompressed size —
a problem for files with very large data pages (e.g. a 1 GB compressed / 1.4 GB
uncompressed page). This adds an **opt-in** path that keeps peak memory near
the requested batch size for the common large-page shape.
Approach was discussed and agreed on the issue.
### What changes are included in this PR
Opt-in via `ReaderProperties.PageStreamingEnabled` (default **false**, no
behavior change). When set, **eligible** data pages skip full-page
decompression and decode their values incrementally; every ineligible page
falls back to the existing materialized path.
**Scope (first cut):**
- Data Page **V1 + V2**, **PLAIN** encoding
- Physical types **`byte_array`** and **`fixed_len_byte_array`**
- Codecs **`UNCOMPRESSED` / `GZIP` / `BROTLI` / `ZSTD`** (explicit
allowlist — Snappy is a raw block whereas its `NewReader` is the framed format,
and LZ4_RAW has no streaming reader)
**Design:**
- `parquet/internal/encoding/streaming`: a small `ValueBuffer` — an
incremental byte source that reads from an `io.Reader`, slides as values are
consumed, grows to fit a single oversized value, and owns the page's stream
(drained + closed on release).
- The materialized `PlainByteArrayDecoder` /
`PlainFixedLenByteArrayDecoder` keep their `[]byte` logic byte-for-byte,
gaining only a `src` field + a one-line guard in `Decode`/`Discard`. Streaming
decode lives beside them; `SetData` resets `src`, so a cached decoder reused
for a materialized page reverts. Other decoders are untouched.
- Page reader: for eligible pages, V2 levels are read raw (uncompressed,
header byte-lengths) and V1 levels are peeled off the decompressed stream using
the same length rules as `LevelDecoder.SetData`; values become a `ValueBuffer`
over the compressed region. On page release the underlying stream is drained +
closed so the reader lands on the next page header even if values were skipped.
- Column reader: the same cached PLAIN decoder is fed via `SetSource`
(streaming) or `SetData` (materialized). `Page`/`DataPage` already return
internal types (e.g. `format.Encoding`), so they can't be implemented outside
`parquet/`; the added **unexported** `valueBuffer`/`valueClip` accessors are
internal plumbing invisible to external consumers.
### Are these changes tested?
Yes.
- All existing encoding tests pass unchanged (materialized parity); the
streaming decoders are additionally exercised with a one-byte-at-a-time reader
and oversized values.
- End-to-end round-trip (`parquet/file/streaming_page_test.go`): streaming
vs materialized reads are asserted identical across V1/V2 and each codec, for
**required** and **nullable** columns (the nullable case covers the V1
def-level peel + streaming `DecodeSpaced`), including values larger than a page
and the stream buffer.
### Are there any user-facing changes?
One new opt-in field, `ReaderProperties.PageStreamingEnabled` (default
false). No behavior change to decoded values; the only interface change is two
unexported accessor methods on `DataPage` (see above).
Follow-ups (out of scope here): fixed-width numeric PLAIN types, raw-Snappy
and LZ4_RAW streaming, and a memory benchmark.
---
parquet/compress/zstd.go | 19 +-
parquet/file/column_reader.go | 44 +-
parquet/file/export_test.go | 21 +
parquet/file/page_reader.go | 307 +++++--
parquet/file/page_reader_internal_test.go | 54 ++
parquet/file/row_group_reader.go | 28 +
parquet/file/streaming_page_test.go | 942 +++++++++++++++++++++
parquet/internal/encoding/byte_array_decoder.go | 72 ++
.../encoding/fixed_len_byte_array_decoder.go | 48 ++
.../internal/encoding/streaming/streaming_test.go | 70 ++
.../internal/encoding/streaming/value_buffer.go | 174 ++++
.../streaming/value_buffer_internal_test.go | 136 +++
.../internal/encoding/streaming_decoder_test.go | 120 +++
parquet/reader_properties.go | 20 +-
14 files changed, 1983 insertions(+), 72 deletions(-)
diff --git a/parquet/compress/zstd.go b/parquet/compress/zstd.go
index a046a48a..c6795c52 100644
--- a/parquet/compress/zstd.go
+++ b/parquet/compress/zstd.go
@@ -112,14 +112,27 @@ func (zstdCodec) Decode(dst, src []byte) []byte {
return dst
}
+var globalDecoderPool = sync.Pool{
+ New: func() interface{} {
+ dec, _ := zstd.NewReader(nil, zstd.WithDecoderConcurrency(1))
+ return dec
+ },
+}
+
func (z *zstdcloser) Close() error {
- z.Decoder.Close()
+ if z.Decoder == nil {
+ return nil
+ }
+ _ = z.Reset(nil)
+ globalDecoderPool.Put(z.Decoder)
+ z.Decoder = nil
return nil
}
func (zstdCodec) NewReader(r io.Reader) io.ReadCloser {
- ret, _ := zstd.NewReader(r)
- return &zstdcloser{ret}
+ dec := globalDecoderPool.Get().(*zstd.Decoder)
+ _ = dec.Reset(r)
+ return &zstdcloser{dec}
}
func (zstdCodec) NewWriter(w io.Writer) io.WriteCloser {
diff --git a/parquet/file/column_reader.go b/parquet/file/column_reader.go
index 48c392b9..b9c96f73 100644
--- a/parquet/file/column_reader.go
+++ b/parquet/file/column_reader.go
@@ -25,6 +25,7 @@ import (
"github.com/apache/arrow-go/v18/internal/utils"
"github.com/apache/arrow-go/v18/parquet"
"github.com/apache/arrow-go/v18/parquet/internal/encoding"
+ "github.com/apache/arrow-go/v18/parquet/internal/encoding/streaming"
"github.com/apache/arrow-go/v18/parquet/internal/encryption"
format "github.com/apache/arrow-go/v18/parquet/internal/gen-go/parquet"
"github.com/apache/arrow-go/v18/parquet/schema"
@@ -261,9 +262,7 @@ func (c *columnChunkReader) setPageReader(rdr PageReader) {
// Close closes the page raeder and the page if set.
func (c *columnChunkReader) Close() error {
- if c.curPage != nil {
- c.curPage.Release()
- }
+ // c.rdr owns curPage (c.curPage == c.rdr.Page()) and releases it on
Close.
if c.rdr != nil {
return c.rdr.Close()
}
@@ -366,7 +365,7 @@ func (c *columnChunkReader) processPage() (bool, error) {
return true, err
}
- return true, c.initDataDecoder(c.curPage, lvlByteLen)
+ return true, c.initDataDecoder(c.curPage.(DataPage), lvlByteLen)
}
// read a new page from the page reader
@@ -398,7 +397,7 @@ func (c *columnChunkReader) readNewPage() bool {
func (c *columnChunkReader) initLevelDecodersV2(page *DataPageV2) (int64,
error) {
c.numBuffered = int64(page.nvals)
c.numDecoded = 0
- buf := page.Data()
+ buf := page.levelBuffer()
totalLvlLen := int64(page.repLvlByteLen) + int64(page.defLvlByteLen)
if totalLvlLen > int64(len(buf)) {
@@ -428,7 +427,7 @@ func (c *columnChunkReader) initLevelDecodersV1(page
*DataPageV1, repLvlEncoding
c.numBuffered = int64(page.nvals)
c.numDecoded = 0
- buf := page.Data()
+ buf := page.levelBuffer()
maxSize := len(buf)
levelsByteLen := int64(0)
@@ -456,13 +455,7 @@ func (c *columnChunkReader) initLevelDecodersV1(page
*DataPageV1, repLvlEncoding
return levelsByteLen, nil
}
-func (c *columnChunkReader) initDataDecoder(page Page, lvlByteLen int64) error
{
- buf := page.Data()
- if int64(len(buf)) < lvlByteLen {
- return errors.New("parquet: page smaller than size of encoded
levels")
- }
-
- buf = buf[lvlByteLen:]
+func (c *columnChunkReader) initDataDecoder(page DataPage, lvlByteLen int64)
error {
encoding := page.Encoding()
if isDictIndexEncoding(encoding) {
@@ -498,8 +491,24 @@ func (c *columnChunkReader) initDataDecoder(page Page,
lvlByteLen int64) error {
}
c.curEncoding = encoding
- c.curDecoder.SetData(int(c.numBuffered), buf)
- return nil
+ return c.setDecoderData(page, lvlByteLen)
+}
+
+func (c *columnChunkReader) setDecoderData(page DataPage, lvlByteLen int64)
error {
+ if src := page.valueBuffer(); src != nil {
+ sd, ok := c.curDecoder.(streaming.Decoder)
+ if !ok {
+ return fmt.Errorf("parquet: streaming page but %s
decoder does not support streaming", c.descr.PhysicalType())
+ }
+ sd.SetSource(int(c.numBuffered), src)
+ return nil
+ }
+
+ buf := page.Data()
+ if int64(len(buf)) < lvlByteLen {
+ return errors.New("parquet: page smaller than size of encoded
levels")
+ }
+ return c.curDecoder.SetData(int(c.numBuffered), buf[lvlByteLen:])
}
// readDefinitionLevels decodes the definition levels from the page and returns
@@ -720,6 +729,11 @@ func (c *columnChunkReader) readBatchInPage(batchSize
int64, totalRead int64, de
return 0, 0, c.err
}
+ // Streaming holds a whole batch's aliased values at once; clip it near
the buffer size.
+ if clip := c.curPage.(DataPage).valueClip(); clip > 0 && batchSize >
clip {
+ batchSize = clip
+ }
+
ndefs, toRead, err := c.determineNumToRead(batchSize, defLvls, repLvls)
if err != nil {
return 0, 0, err
diff --git a/parquet/file/export_test.go b/parquet/file/export_test.go
new file mode 100644
index 00000000..ada4b966
--- /dev/null
+++ b/parquet/file/export_test.go
@@ -0,0 +1,21 @@
+// 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 file
+
+// StreamingThreshold exposes the package-level streaming page-size threshold
so tests
+// can lower it and exercise streaming on small pages.
+var StreamingThreshold = &streamingThreshold
diff --git a/parquet/file/page_reader.go b/parquet/file/page_reader.go
index 66b8473a..50aef89c 100644
--- a/parquet/file/page_reader.go
+++ b/parquet/file/page_reader.go
@@ -17,16 +17,20 @@
package file
import (
+ "encoding/binary"
"errors"
"fmt"
"io"
+ "math/bits"
"sort"
"sync"
+ "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/compress"
+ "github.com/apache/arrow-go/v18/parquet/internal/encoding/streaming"
"github.com/apache/arrow-go/v18/parquet/internal/encryption"
format "github.com/apache/arrow-go/v18/parquet/internal/gen-go/parquet"
"github.com/apache/arrow-go/v18/parquet/internal/thrift"
@@ -68,7 +72,11 @@ const (
type Page interface {
// Returns which kind of page this is
Type() PageType
- // Get the raw bytes of this page
+ // Get the raw bytes of this page.
+ //
+ // Note: with PageStreamingEnabled, a streaming data page's Data holds
only the
+ // rep/def level region, not the whole page; decode values through a
column or
+ // record reader instead.
Data() []byte
// return the encoding used for this page, Plain/RLE, etc.
Encoding() format.Encoding
@@ -91,6 +99,43 @@ func (p *page) Data() []byte { return
p.buf.Bytes() }
func (p *page) NumValues() int32 { return p.nvals }
func (p *page) Encoding() format.Encoding { return p.encoding }
+// dataPageBase holds the members shared by DataPageV1/V2 but not dictionary
+// pages: the rep/def level buffer accessor and, in streaming mode, the
+// incremental value source.
+type dataPageBase struct {
+ page
+
+ // valueBuf is only set for streaming mode to read values incrementally.
+ // In such mode, buf only holds the rep+def level region.
+ valueBuf streaming.ValueBuffer
+ // clip bounds a read's batch to keep one Decode's aliased values near
the buffer
+ // size; 0 for non-streaming pages.
+ clip int64
+}
+
+func (d *dataPageBase) valueBuffer() streaming.ValueBuffer { return d.valueBuf
}
+func (d *dataPageBase) levelBuffer() []byte { return
d.buf.Bytes() }
+
+func (d *dataPageBase) valueClip() int64 { return d.clip }
+
+func (d *dataPageBase) setStreamingValues(mem memory.Allocator,
lenUncompressed int, levelBuf *memory.Buffer, valReader, rawSrc io.Reader,
closer io.Closer) {
+ d.buf = levelBuf
+ valueBytes := lenUncompressed - levelBuf.Len()
+ d.valueBuf = streaming.NewStreamBuffer(mem, valReader, valueBytes,
drainAndClose(rawSrc, closer))
+ d.clip = streaming.ClipBatch(valueBytes, int(d.nvals))
+}
+
+// releaseValueStream closes a streaming page's value source (draining +
closing its
+// underlying compressed stream so the file reader lands on the next page
header even
+// when values were skipped). It is a no-op for non-streaming pages.
+func (d *dataPageBase) releaseValueStream() {
+ if d.valueBuf != nil {
+ _ = d.valueBuf.Close()
+ d.valueBuf = nil
+ }
+ d.clip = 0
+}
+
// DataPageConfig is a struct for passing configuration params to data page
creation
// which can be expanded in the future without causing any breaking changes.
type DataPageConfig struct {
@@ -111,6 +156,11 @@ type DataPage interface {
// FirstRowIndex returns the row ordinal within the row group
// to the first row in the data page, or -1 if not set.
FirstRowIndex() int64
+
+ // valueBuffer returns the streaming value source, or nil for a
non-streaming page.
+ valueBuffer() streaming.ValueBuffer
+ // valueClip bounds a read's batch on a streaming page, or 0 for a
non-streaming page.
+ valueClip() int64
}
// Create some pools to use for reusing the data page objects themselves so
that
@@ -133,7 +183,7 @@ var dictPagePool = sync.Pool{
// DataPageV1 represents a DataPage version 1 from the parquet.thrift file
type DataPageV1 struct {
- page
+ dataPageBase
defLvlEncoding format.Encoding
repLvlEncoding format.Encoding
@@ -152,7 +202,7 @@ func NewDataPageV1(buffer *memory.Buffer, num int32,
encoding, defEncoding, repE
dp := dataPageV1Pool.Get().(*DataPageV1)
if dp == nil {
return &DataPageV1{
- page: page{buf: buffer, typ:
format.PageType_DATA_PAGE, nvals: num, encoding: format.Encoding(encoding)},
+ dataPageBase: dataPageBase{page: page{buf: buffer,
typ: format.PageType_DATA_PAGE, nvals: num, encoding:
format.Encoding(encoding)}},
defLvlEncoding: format.Encoding(defEncoding),
repLvlEncoding: format.Encoding(repEncoding),
uncompressedSize: uncompressedSize,
@@ -191,6 +241,10 @@ func NewDataPageV1WithConfig(buffer *memory.Buffer,
defEncoding, repEncoding par
// After calling this function, the object should not be utilized anymore,
otherwise
// conflicts can arise.
func (d *DataPageV1) Release() {
+ if d.buf == nil {
+ return
+ }
+ d.releaseValueStream()
d.buf.Release()
d.buf = nil
dataPageV1Pool.Put(d)
@@ -216,7 +270,7 @@ func (d *DataPageV1) RepetitionLevelEncoding()
parquet.Encoding {
// DataPageV2 is the representation of the V2 data page from the
parquet.thrift spec
type DataPageV2 struct {
- page
+ dataPageBase
nulls int32
nrows int32
@@ -234,7 +288,7 @@ func NewDataPageV2(buffer *memory.Buffer, numValues,
numNulls, numRows int32, en
dp := dataPageV2Pool.Get().(*DataPageV2)
if dp == nil {
return &DataPageV2{
- page: page{buf: buffer, typ:
format.PageType_DATA_PAGE_V2, nvals: numValues, encoding:
format.Encoding(encoding)},
+ dataPageBase: dataPageBase{page: page{buf: buffer,
typ: format.PageType_DATA_PAGE_V2, nvals: numValues, encoding:
format.Encoding(encoding)}},
nulls: numNulls,
nrows: numRows,
defLvlByteLen: defLvlsByteLen,
@@ -277,6 +331,10 @@ func NewDataPageV2WithConfig(buffer *memory.Buffer,
numNulls, numRows int32, def
// After calling this function, the object should not be utilized anymore,
otherwise
// conflicts can arise.
func (d *DataPageV2) Release() {
+ if d.buf == nil {
+ return
+ }
+ d.releaseValueStream()
d.buf.Release()
d.buf = nil
dataPageV2Pool.Put(d)
@@ -358,6 +416,11 @@ type serializedPageReader struct {
mem memory.Allocator
codec compress.Codec
+ // Streaming (PageStreamingEnabled) inputs from schema/properties.
+ columnCanStream bool
+ maxRepLevel int16
+ maxDefLevel int16
+
curPageHdr *format.PageHeader
pageOrd int16
maxPageHeaderSize int
@@ -376,6 +439,11 @@ type serializedPageReader struct {
}
func (p *serializedPageReader) Close() error {
+ if p.curPage != nil {
+ // free a direct PageReader user's streaming value stream
+ p.curPage.Release()
+ p.curPage = nil
+ }
if p.decompressBuffer != nil {
p.decompressBuffer.Release()
p.dictPageBuffer.Release()
@@ -398,6 +466,11 @@ func (p *serializedPageReader) init(compressType
compress.Compression, ctx *Cryp
return err
}
p.codec = codec
+ if _, ok := codec.(compress.StreamingCodec); !ok {
+ // A codec registered via compress.RegisterCodec need not
implement
+ // streaming; disable streaming for this column in that case.
+ p.columnCanStream = false
+ }
if ctx != nil {
p.cryptoCtx = *ctx
@@ -448,9 +521,13 @@ func NewPageReader(r parquet.BufferedReader, nrows int64,
compressType compress.
}
func (p *serializedPageReader) Reset(r parquet.BufferedReader, nrows int64,
compressType compress.Compression, ctx *CryptoContext) {
+ if p.curPage != nil {
+ p.curPage.Release()
+ }
p.rowsSeen, p.pageOrd, p.nrows = 0, 0, nrows
p.curPageHdr, p.curPage, p.err = nil, nil, nil
p.r = r
+ p.columnCanStream = false
p.codec, p.err = compress.GetCodec(compressType)
if p.err != nil {
@@ -723,6 +800,13 @@ func (p *serializedPageReader) SeekToPageWithRow(rowIdx
int64) error {
return fmt.Errorf("parquet: cannot seek column reader to row
index %d", rowIdx)
}
+ // Release before repositioning p.r: a streaming page's
drain-on-release would
+ // otherwise read from the new offset.
+ if p.curPage != nil {
+ p.curPage.Release()
+ p.curPage = nil
+ }
+
var (
oidx metadata.OffsetIndex
err error
@@ -769,6 +853,92 @@ func (p *serializedPageReader) SeekToPageWithRow(rowIdx
int64) error {
return p.err
}
+func (p *serializedPageReader) valueStream(limit io.Reader, compressed bool)
(io.Reader, io.Closer) {
+ if compressed {
+ dec := p.codec.(compress.StreamingCodec).NewReader(limit)
+ return dec, dec
+ }
+ return limit, nil
+}
+
+// drainAndClose returns the cleanup a streaming ValueBuffer runs on Close:
consume
+// any unread compressed bytes for the current page and close the decompressor.
+func drainAndClose(rawSrc io.Reader, closer io.Closer) func() error {
+ return func() error {
+ _, _ = io.Copy(io.Discard, rawSrc)
+ if closer != nil {
+ return closer.Close()
+ }
+ return nil
+ }
+}
+
+// streamingThreshold is the smallest data-page size worth streaming (var so
tests can lower it).
+var streamingThreshold = int64(1 << 20)
+
+func (p *serializedPageReader) pageCanStream(enc format.Encoding,
lenUncompressed int) bool {
+ return p.columnCanStream && enc == format.Encoding_PLAIN &&
p.cryptoCtx.DataDecryptor == nil &&
+ int64(lenUncompressed) > streamingThreshold
+}
+
+// readLevelData reads the V1 rep+def level region, capped at maxBytes (the
+// uncompressed page size) so a bogus length prefix can't force a huge
allocation.
+func (p *serializedPageReader) readLevelData(r io.Reader, repEnc, defEnc
format.Encoding, numValues, maxBytes int) (*memory.Buffer, error) {
+ buf := memory.NewResizableBuffer(p.mem)
+ pos := 0
+ readOne := func(enc format.Encoding, maxLvl int16) error {
+ switch enc {
+ case format.Encoding_RLE:
+ var hdr [4]byte
+ if _, err := io.ReadFull(r, hdr[:]); err != nil {
+ return err
+ }
+ n := int(binary.LittleEndian.Uint32(hdr[:]))
+ if n < 0 || pos+4+n > maxBytes {
+ return fmt.Errorf("parquet: invalid V1 level
region length %d", n)
+ }
+ buf.Resize(pos + 4 + n)
+ copy(buf.Bytes()[pos:], hdr[:])
+ if _, err := io.ReadFull(r,
buf.Bytes()[pos+4:pos+4+n]); err != nil {
+ return err
+ }
+ pos += 4 + n
+ case format.Encoding_BIT_PACKED:
+ bitWidth := bits.Len64(uint64(maxLvl))
+ nbits, ok := utils.Mul(numValues, bitWidth)
+ if !ok {
+ return fmt.Errorf("parquet: V1 level count %d
too large (corrupt page?)", numValues)
+ }
+ n := int(bitutil.BytesForBits(int64(nbits)))
+ if n < 0 || pos+n > maxBytes {
+ return fmt.Errorf("parquet: invalid V1 level
region length %d", n)
+ }
+ buf.Resize(pos + n)
+ if _, err := io.ReadFull(r, buf.Bytes()[pos:pos+n]);
err != nil {
+ return err
+ }
+ pos += n
+ default:
+ return fmt.Errorf("parquet: unsupported V1 level
encoding %s for streaming", enc)
+ }
+ return nil
+ }
+
+ if p.maxRepLevel > 0 {
+ if err := readOne(repEnc, p.maxRepLevel); err != nil {
+ buf.Release()
+ return nil, err
+ }
+ }
+ if p.maxDefLevel > 0 {
+ if err := readOne(defEnc, p.maxDefLevel); err != nil {
+ buf.Release()
+ return nil, err
+ }
+ }
+ return buf, nil
+}
+
func (p *serializedPageReader) Next() bool {
// Loop here because there may be unhandled page types that we skip
until
// finding a page that we do know what to do with
@@ -840,35 +1010,50 @@ func (p *serializedPageReader) Next() bool {
return false
}
- p.dataPageBuffer.ResizeNoShrink(lenUncompressed)
- buf := memory.NewBufferBytes(p.dataPageBuffer.Bytes())
-
firstRowIdx := p.rowsSeen
p.rowsSeen += int64(dataHeader.GetNumValues())
- data, err := p.decompress(p.r, lenCompressed,
buf.Bytes())
- if err != nil {
- p.err = err
- return false
- }
- if len(data) != lenUncompressed {
- p.err = fmt.Errorf("parquet: metadata said %d
bytes uncompressed data page, got %d bytes", lenUncompressed, len(data))
- return false
- }
- // make datapagev1
- p.curPage = &DataPageV1{
- page: page{
- buf: buf,
+ dp := &DataPageV1{
+ dataPageBase: dataPageBase{page: page{
typ: p.curPageHdr.Type,
nvals: dataHeader.GetNumValues(),
encoding: dataHeader.GetEncoding(),
- },
+ }},
defLvlEncoding:
dataHeader.GetDefinitionLevelEncoding(),
repLvlEncoding:
dataHeader.GetRepetitionLevelEncoding(),
uncompressedSize: int32(lenUncompressed),
statistics: extractStats(dataHeader),
firstRowIndex: firstRowIdx,
}
+
+ if p.pageCanStream(dataHeader.GetEncoding(),
lenUncompressed) {
+ limit := io.LimitReader(p.r,
int64(lenCompressed))
+ // V1 compresses the whole body, so the levels
are read through the
+ // codec too (a no-op passthrough for an
uncompressed column).
+ valReader, closer := p.valueStream(limit, true)
+ levelBuf, err := p.readLevelData(valReader,
dataHeader.GetRepetitionLevelEncoding(),
+
dataHeader.GetDefinitionLevelEncoding(), int(dataHeader.GetNumValues()),
lenUncompressed)
+ if err != nil {
+ _ = drainAndClose(limit, closer)()
+ p.err = err
+ return false
+ }
+ dp.setStreamingValues(p.mem, lenUncompressed,
levelBuf, valReader, limit, closer)
+ } else {
+ p.dataPageBuffer.ResizeNoShrink(lenUncompressed)
+ buf :=
memory.NewBufferBytes(p.dataPageBuffer.Bytes())
+ data, err := p.decompress(p.r, lenCompressed,
buf.Bytes())
+ if err != nil {
+ p.err = err
+ return false
+ }
+ if len(data) != lenUncompressed {
+ p.err = fmt.Errorf("parquet: metadata
said %d bytes uncompressed data page, got %d bytes", lenUncompressed, len(data))
+ return false
+ }
+ dp.buf = buf
+ }
+ p.curPage = dp
case format.PageType_DATA_PAGE_V2:
p.pageOrd++
dataHeader := p.curPageHdr.GetDataPageHeaderV2()
@@ -882,11 +1067,7 @@ func (p *serializedPageReader) Next() bool {
return false
}
- p.dataPageBuffer.ResizeNoShrink(lenUncompressed)
- buf := memory.NewBufferBytes(p.dataPageBuffer.Bytes())
-
compressed := dataHeader.GetIsCompressed()
- // extract stats
firstRowIdx := p.rowsSeen
p.rowsSeen += int64(dataHeader.GetNumRows())
levelsBytelen, ok :=
utils.Add(int(dataHeader.GetDefinitionLevelsByteLength()),
int(dataHeader.GetRepetitionLevelsByteLength()))
@@ -894,37 +1075,17 @@ func (p *serializedPageReader) Next() bool {
p.err = errors.New("parquet: levels size too
large (corrupt file?)")
return false
}
-
- if p.cryptoCtx.DataDecryptor != nil {
- got, err := p.readV2Encrypted(p.r,
lenCompressed, lenUncompressed, levelsBytelen, compressed, buf.Bytes())
- if err != nil {
- p.err = err
- return false
- }
- if got != lenUncompressed {
- p.err = fmt.Errorf("parquet: metadata
said %d bytes uncompressed data page, got %d bytes", lenUncompressed, got)
- return false
- }
- } else {
- got, err := p.readV2Unencrypted(p.r,
lenCompressed, lenUncompressed, levelsBytelen, compressed, buf.Bytes())
- if err != nil {
- p.err = err
- return false
- }
- if got != lenUncompressed {
- p.err = fmt.Errorf("parquet: metadata
said %d bytes uncompressed data page, got %d bytes", lenUncompressed, got)
- return false
- }
+ if levelsBytelen > lenCompressed {
+ p.err = fmt.Errorf("parquet: V2 level region %d
exceeds page body %d (corrupt page?)", levelsBytelen, lenCompressed)
+ return false
}
- // make datapage v2
- p.curPage = &DataPageV2{
- page: page{
- buf: buf,
+ dp := &DataPageV2{
+ dataPageBase: dataPageBase{page: page{
typ: p.curPageHdr.Type,
nvals: dataHeader.GetNumValues(),
encoding: dataHeader.GetEncoding(),
- },
+ }},
nulls: dataHeader.GetNumNulls(),
nrows: dataHeader.GetNumRows(),
defLvlByteLen:
dataHeader.GetDefinitionLevelsByteLength(),
@@ -934,6 +1095,48 @@ func (p *serializedPageReader) Next() bool {
statistics: extractStats(dataHeader),
firstRowIndex: firstRowIdx,
}
+
+ if p.pageCanStream(dataHeader.GetEncoding(),
lenUncompressed) {
+ // V2: rep/def levels are uncompressed and
precede the separately
+ // compressed value region. Read the level
bytes directly off the front,
+ // then stream the values from what remains.
+ limit := io.LimitReader(p.r,
int64(lenCompressed))
+ levelBuf := memory.NewResizableBuffer(p.mem)
+ levelBuf.Resize(levelsBytelen)
+ if _, err := io.ReadFull(limit,
levelBuf.Bytes()); err != nil {
+ levelBuf.Release()
+ p.err = err
+ return false
+ }
+ valReader, closer := p.valueStream(limit,
compressed)
+ dp.setStreamingValues(p.mem, lenUncompressed,
levelBuf, valReader, limit, closer)
+ } else {
+ p.dataPageBuffer.ResizeNoShrink(lenUncompressed)
+ buf :=
memory.NewBufferBytes(p.dataPageBuffer.Bytes())
+ if p.cryptoCtx.DataDecryptor != nil {
+ got, err := p.readV2Encrypted(p.r,
lenCompressed, lenUncompressed, levelsBytelen, compressed, buf.Bytes())
+ if err != nil {
+ p.err = err
+ return false
+ }
+ if got != lenUncompressed {
+ p.err = fmt.Errorf("parquet:
metadata said %d bytes uncompressed data page, got %d bytes", lenUncompressed,
got)
+ return false
+ }
+ } else {
+ got, err := p.readV2Unencrypted(p.r,
lenCompressed, lenUncompressed, levelsBytelen, compressed, buf.Bytes())
+ if err != nil {
+ p.err = err
+ return false
+ }
+ if got != lenUncompressed {
+ p.err = fmt.Errorf("parquet:
metadata said %d bytes uncompressed data page, got %d bytes", lenUncompressed,
got)
+ return false
+ }
+ }
+ dp.buf = buf
+ }
+ p.curPage = dp
default:
// we don't know this page type, we're allowed to skip
non-data pages
continue
diff --git a/parquet/file/page_reader_internal_test.go
b/parquet/file/page_reader_internal_test.go
new file mode 100644
index 00000000..1ccd823e
--- /dev/null
+++ b/parquet/file/page_reader_internal_test.go
@@ -0,0 +1,54 @@
+// 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 file
+
+import (
+ "bytes"
+ "encoding/binary"
+ "testing"
+
+ "github.com/apache/arrow-go/v18/arrow/memory"
+ format "github.com/apache/arrow-go/v18/parquet/internal/gen-go/parquet"
+ "github.com/stretchr/testify/require"
+)
+
+func TestReadLevelDataRLELengthBound(t *testing.T) {
+ p := &serializedPageReader{maxDefLevel: 1, mem: memory.DefaultAllocator}
+
+ // a complete 8-byte RLE level region, so the reject below is the
bound, not EOF
+ data := make([]byte, 4+8)
+ binary.LittleEndian.PutUint32(data[:4], 8)
+
+ _, err := p.readLevelData(bytes.NewReader(data), format.Encoding_RLE,
format.Encoding_RLE, 4, 8)
+ require.Error(t, err)
+
+ out, err := p.readLevelData(bytes.NewReader(data), format.Encoding_RLE,
format.Encoding_RLE, 4, 1024)
+ require.NoError(t, err)
+ require.Equal(t, 12, out.Len())
+ out.Release()
+}
+
+func TestReadLevelDataBitPacked(t *testing.T) {
+ p := &serializedPageReader{maxDefLevel: 1, mem: memory.DefaultAllocator}
+
+ // maxDef 1 -> bitWidth 1; 8 values -> BytesForBits(8) = 1 byte, no
length prefix
+ data := []byte{0b10110100}
+ out, err := p.readLevelData(bytes.NewReader(data), format.Encoding_RLE,
format.Encoding_BIT_PACKED, 8, 1024)
+ require.NoError(t, err)
+ require.Equal(t, data, out.Bytes())
+ out.Release()
+}
diff --git a/parquet/file/row_group_reader.go b/parquet/file/row_group_reader.go
index 295ccc1c..2052a261 100644
--- a/parquet/file/row_group_reader.go
+++ b/parquet/file/row_group_reader.go
@@ -23,6 +23,7 @@ import (
"github.com/apache/arrow-go/v18/internal/utils"
"github.com/apache/arrow-go/v18/parquet"
+ "github.com/apache/arrow-go/v18/parquet/compress"
"github.com/apache/arrow-go/v18/parquet/internal/encoding"
"github.com/apache/arrow-go/v18/parquet/internal/encryption"
format "github.com/apache/arrow-go/v18/parquet/internal/gen-go/parquet"
@@ -121,6 +122,7 @@ func (r *RowGroupReader) GetColumnPageReader(i int)
(PageReader, error) {
cryptoMetadata := col.CryptoMetadata()
if cryptoMetadata == nil {
+ descr := r.fileMetadata.Schema.Column(i)
pr := &serializedPageReader{
r: stream,
chunk: col,
@@ -129,6 +131,13 @@ func (r *RowGroupReader) GetColumnPageReader(i int)
(PageReader, error) {
maxPageHeaderSize: defaultMaxPageHeaderSize,
nrows: col.NumValues(),
mem: r.props.Allocator(),
+ // Streaming (PageStreamingEnabled) inputs; only the
unencrypted path is
+ // ever streaming-eligible, so columnCanStream is left
false elsewhere.
+ columnCanStream: r.props.PageStreamingEnabled &&
+ streamablePhysicalType(descr.PhysicalType()) &&
+ streamableCodec(col.Compression()),
+ maxRepLevel: descr.MaxRepetitionLevel(),
+ maxDefLevel: descr.MaxDefinitionLevel(),
}
return pr, pr.init(col.Compression(), nil)
}
@@ -186,3 +195,22 @@ func (r *RowGroupReader) GetColumnPageReader(i int)
(PageReader, error) {
}
return pr, pr.init(col.Compression(), &ctx)
}
+
+func streamablePhysicalType(t parquet.Type) bool {
+ switch t {
+ case parquet.Types.ByteArray, parquet.Types.FixedLenByteArray:
+ return true
+ default:
+ return false
+ }
+}
+
+func streamableCodec(c compress.Compression) bool {
+ switch c {
+ case compress.Codecs.Uncompressed, compress.Codecs.Gzip,
compress.Codecs.Brotli, compress.Codecs.Zstd:
+ return true
+ default:
+ // Snappy/LZ4_RAW register as StreamingCodecs but use block
framing, not a spec-compliant stream.
+ return false
+ }
+}
diff --git a/parquet/file/streaming_page_test.go
b/parquet/file/streaming_page_test.go
new file mode 100644
index 00000000..68d606e6
--- /dev/null
+++ b/parquet/file/streaming_page_test.go
@@ -0,0 +1,942 @@
+// 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 file_test
+
+import (
+ "bytes"
+ "fmt"
+ "os"
+ "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/compress"
+ "github.com/apache/arrow-go/v18/parquet/file"
+ "github.com/apache/arrow-go/v18/parquet/schema"
+ "github.com/stretchr/testify/require"
+)
+
+// TestMain lowers the streaming page-size threshold so the streaming tests
exercise
+// the streaming path on their small (fast) pages.
+func TestMain(m *testing.M) {
+ *file.StreamingThreshold = 0
+ os.Exit(m.Run())
+}
+
+func makeStreamTestValues(sizes []int) []parquet.ByteArray {
+ values := make([]parquet.ByteArray, len(sizes))
+ for i, sz := range sizes {
+ buf := make([]byte, sz)
+ buf[0] = byte(i)
+ for j := 1; j < sz; j++ {
+ buf[j] = byte(i*31 + j)
+ }
+ values[i] = buf
+ }
+ return values
+}
+
+func writeByteArrayStream(t *testing.T, values []parquet.ByteArray, defLevels,
repLevels []int16, rep parquet.Repetition, ver parquet.DataPageVersion, codec
compress.Compression, pageSize int64) []byte {
+ t.Helper()
+ sc := schema.NewSchema(schema.MustGroup(schema.NewGroupNode("schema",
parquet.Repetitions.Required, schema.FieldList{
+ schema.Must(schema.NewPrimitiveNode("data", rep,
parquet.Types.ByteArray, -1, -1)),
+ }, -1)))
+ props := parquet.NewWriterProperties(
+ parquet.WithDictionaryDefault(false), // ensure PLAIN data pages
+ parquet.WithDataPageVersion(ver),
+ parquet.WithCompression(codec),
+ parquet.WithDataPageSize(pageSize),
+ )
+ out := &bytes.Buffer{}
+ w := file.NewParquetWriter(out, sc.Root(), file.WithWriterProps(props))
+ rgw := w.AppendRowGroup()
+ cw, err := rgw.NextColumn()
+ require.NoError(t, err)
+ _, err = cw.(*file.ByteArrayColumnChunkWriter).WriteBatch(values,
defLevels, repLevels)
+ require.NoError(t, err)
+ require.NoError(t, cw.Close())
+ require.NoError(t, rgw.Close())
+ require.NoError(t, w.Close())
+ return out.Bytes()
+}
+
+// writeStreamTestColumn writes a Required byte-array column with small (1
KiB) pages.
+func writeStreamTestColumn(t *testing.T, values []parquet.ByteArray, ver
parquet.DataPageVersion, codec compress.Compression) []byte {
+ return writeByteArrayStream(t, values, nil, nil,
parquet.Repetitions.Required, ver, codec, 1024)
+}
+
+// collectByteArray reads exactly n values from bar via repeated ReadBatch
calls.
+func collectByteArray(t *testing.T, bar *file.ByteArrayColumnChunkReader, n
int) []parquet.ByteArray {
+ t.Helper()
+ out := make([]parquet.ByteArray, n)
+ total := 0
+ for total < n {
+ _, nv, err := bar.ReadBatch(int64(n-total), out[total:], nil,
nil)
+ require.NoError(t, err)
+ if nv == 0 {
+ break
+ }
+ total += nv
+ }
+ require.Equal(t, n, total)
+ return out
+}
+
+func readStreamTestColumn(t *testing.T, data []byte, streaming bool)
[]parquet.ByteArray {
+ t.Helper()
+ props := parquet.NewReaderProperties(memory.DefaultAllocator)
+ props.PageStreamingEnabled = streaming
+
+ rdr, err := file.NewParquetReader(bytes.NewReader(data),
file.WithReadProps(props))
+ require.NoError(t, err)
+ defer rdr.Close()
+
+ cr, err := rdr.RowGroup(0).Column(0)
+ require.NoError(t, err)
+ bar := cr.(*file.ByteArrayColumnChunkReader)
+ return collectByteArray(t, bar, int(rdr.NumRows()))
+}
+
+func TestPageStreamingByteArrayRoundTrip(t *testing.T) {
+ // Varied sizes: several small, some > 4096 (stream buffer), some >
1024 (page).
+ values := makeStreamTestValues([]int{65, 100, 5000, 200, 9000, 50,
4096, 1})
+
+ cases := []struct {
+ name string
+ ver parquet.DataPageVersion
+ codec compress.Compression
+ }{
+ {"v1-uncompressed", parquet.DataPageV1,
compress.Codecs.Uncompressed},
+ {"v1-zstd", parquet.DataPageV1, compress.Codecs.Zstd},
+ {"v1-gzip", parquet.DataPageV1, compress.Codecs.Gzip},
+ {"v2-uncompressed", parquet.DataPageV2,
compress.Codecs.Uncompressed},
+ {"v2-zstd", parquet.DataPageV2, compress.Codecs.Zstd},
+ {"v2-brotli", parquet.DataPageV2, compress.Codecs.Brotli},
+ }
+
+ for _, tc := range cases {
+ t.Run(tc.name, func(t *testing.T) {
+ data := writeStreamTestColumn(t, values, tc.ver,
tc.codec)
+
+ materialized := readStreamTestColumn(t, data, false)
+ streamed := readStreamTestColumn(t, data, true)
+
+ require.Len(t, materialized, len(values))
+ for i := range values {
+ require.Truef(t, bytes.Equal(values[i],
materialized[i]), "materialized value %d mismatch", i)
+ require.Truef(t, bytes.Equal(values[i],
streamed[i]), "streamed value %d mismatch", i)
+ }
+ })
+ }
+}
+
+// TestPageStreamingEngaged guards against eligibility silently falling back
to the
+// whole-page read: a required column's streaming page holds only the (empty)
level
+// region, while the materialized page's Data() is the full uncompressed page.
+func TestPageStreamingEngaged(t *testing.T) {
+ values := makeStreamTestValues([]int{65, 5000, 200, 9000, 50})
+ data := writeStreamTestColumn(t, values, parquet.DataPageV1,
compress.Codecs.Zstd)
+
+ firstPageDataLen := func(streaming bool) int {
+ props := parquet.NewReaderProperties(memory.DefaultAllocator)
+ props.PageStreamingEnabled = streaming
+ rdr, err := file.NewParquetReader(bytes.NewReader(data),
file.WithReadProps(props))
+ require.NoError(t, err)
+ defer rdr.Close()
+
+ pr, err := rdr.RowGroup(0).GetColumnPageReader(0)
+ require.NoError(t, err)
+ defer pr.Close()
+ require.True(t, pr.Next())
+ return len(pr.Page().Data())
+ }
+
+ require.Zero(t, firstPageDataLen(true), "streaming page Data() should
hold only the empty level region")
+ require.NotZero(t, firstPageDataLen(false), "materialized page Data()
should be the whole page")
+}
+
+func TestPageStreamingAllocatorBalance(t *testing.T) {
+ values := makeStreamTestValues([]int{65, 5000, 200, 9000, 50})
+ data := writeStreamTestColumn(t, values, parquet.DataPageV1,
compress.Codecs.Zstd)
+
+ checked := memory.NewCheckedAllocator(memory.DefaultAllocator)
+ defer checked.AssertSize(t, 0)
+
+ props := parquet.NewReaderProperties(checked)
+ props.PageStreamingEnabled = true
+ rdr, err := file.NewParquetReader(bytes.NewReader(data),
file.WithReadProps(props))
+ require.NoError(t, err)
+ defer rdr.Close()
+
+ cr, err := rdr.RowGroup(0).Column(0)
+ require.NoError(t, err)
+ n := int(rdr.NumRows())
+ out := make([]parquet.ByteArray, n)
+ for total := 0; total < n; {
+ _, nv, err :=
cr.(*file.ByteArrayColumnChunkReader).ReadBatch(int64(n-total), out[total:],
nil, nil)
+ require.NoError(t, err)
+ if nv == 0 {
+ break
+ }
+ total += nv
+ }
+ require.NoError(t, cr.Close())
+}
+
+func TestPageStreamingLeveledAllocatorBalance(t *testing.T) {
+ nullDef := make([]int16, 40)
+ var nullVals []parquet.ByteArray
+ for i := 0; i < 40; i++ {
+ if i%3 == 0 {
+ continue
+ }
+ nullDef[i] = 1
+ buf := make([]byte, 40+i*120)
+ for j := range buf {
+ buf[j] = byte(i*7 + j)
+ }
+ nullVals = append(nullVals, buf)
+ }
+
+ cases := []struct {
+ name string
+ rep parquet.Repetition
+ values []parquet.ByteArray
+ defLevels []int16
+ repLevels []int16
+ }{
+ {"nullable", parquet.Repetitions.Optional, nullVals, nullDef,
nil},
+ {"repeated", parquet.Repetitions.Repeated,
+ makeStreamTestValues([]int{40, 5000, 60, 9000, 70, 80}),
+ []int16{1, 1, 1, 1, 1, 1}, []int16{0, 1, 1, 0, 0, 1}},
+ }
+ for _, tc := range cases {
+ for _, ver := range
[]parquet.DataPageVersion{parquet.DataPageV1, parquet.DataPageV2} {
+ t.Run(fmt.Sprintf("%s/v%d", tc.name, ver+1), func(t
*testing.T) {
+ data := writeByteArrayStream(t, tc.values,
tc.defLevels, tc.repLevels, tc.rep, ver, compress.Codecs.Zstd, 1024)
+
+ checked :=
memory.NewCheckedAllocator(memory.DefaultAllocator)
+ defer checked.AssertSize(t, 0)
+
+ props := parquet.NewReaderProperties(checked)
+ props.PageStreamingEnabled = true
+ rdr, err :=
file.NewParquetReader(bytes.NewReader(data), file.WithReadProps(props))
+ require.NoError(t, err)
+ defer rdr.Close()
+
+ cr, err := rdr.RowGroup(0).Column(0)
+ require.NoError(t, err)
+ bar := cr.(*file.ByteArrayColumnChunkReader)
+ vbuf := make([]parquet.ByteArray, 64)
+ dbuf := make([]int16, 64)
+ rbuf := make([]int16, 64)
+ for {
+ total, _, err := bar.ReadBatch(64,
vbuf, dbuf, rbuf)
+ require.NoError(t, err)
+ if total == 0 {
+ break
+ }
+ }
+ require.NoError(t, cr.Close())
+ })
+ }
+ }
+}
+
+func TestPageStreamingIneligibleCodecFallback(t *testing.T) {
+ values := makeStreamTestValues([]int{65, 5000, 200, 9000, 50})
+
+ for _, codec := range []compress.Compression{compress.Codecs.Snappy,
compress.Codecs.Lz4Raw} {
+ for _, ver := range
[]parquet.DataPageVersion{parquet.DataPageV1, parquet.DataPageV2} {
+ data := writeStreamTestColumn(t, values, ver, codec)
+
+ // PageStreamingEnabled is set, but Snappy/LZ4_RAW are
not in the allowlist,
+ // so pages must fall back to whole-page reads and
match materialized.
+ materialized := readStreamTestColumn(t, data, false)
+ streamed := readStreamTestColumn(t, data, true)
+
+ require.Len(t, streamed, len(values))
+ for i := range values {
+ require.Truef(t, bytes.Equal(values[i],
streamed[i]), "codec %v ver %v value %d", codec, ver, i)
+ require.Truef(t, bytes.Equal(materialized[i],
streamed[i]), "codec %v ver %v value %d: streamed != materialized", codec, ver,
i)
+ }
+ }
+ }
+}
+
+func readNullableStreamColumn(t *testing.T, data []byte, streaming bool,
numRows int) (defLevels []int16, values []parquet.ByteArray) {
+ t.Helper()
+ props := parquet.NewReaderProperties(memory.DefaultAllocator)
+ props.PageStreamingEnabled = streaming
+ rdr, err := file.NewParquetReader(bytes.NewReader(data),
file.WithReadProps(props))
+ require.NoError(t, err)
+ defer rdr.Close()
+
+ cr, err := rdr.RowGroup(0).Column(0)
+ require.NoError(t, err)
+ bar := cr.(*file.ByteArrayColumnChunkReader)
+
+ defLevels = make([]int16, 0, numRows)
+ values = make([]parquet.ByteArray, 0, numRows)
+ for len(defLevels) < numRows {
+ vbuf := make([]parquet.ByteArray, numRows)
+ dbuf := make([]int16, numRows)
+ total, nv, err := bar.ReadBatch(int64(numRows-len(defLevels)),
vbuf, dbuf, nil)
+ require.NoError(t, err)
+ if total == 0 {
+ break
+ }
+ defLevels = append(defLevels, dbuf[:total]...)
+ values = append(values, vbuf[:nv]...)
+ }
+ return defLevels, values
+}
+
+func TestPageStreamingByteArrayNullable(t *testing.T) {
+ const numRows = 40
+ defLevels := make([]int16, numRows)
+ var values []parquet.ByteArray
+ for i := 0; i < numRows; i++ {
+ if i%3 == 0 {
+ defLevels[i] = 0 // null
+ continue
+ }
+ defLevels[i] = 1
+ sz := 40 + i*120 // varied sizes, some > page / stream buffer
+ buf := make([]byte, sz)
+ buf[0] = byte(i)
+ for j := 1; j < sz; j++ {
+ buf[j] = byte(i*7 + j)
+ }
+ values = append(values, buf)
+ }
+
+ cases := []struct {
+ name string
+ ver parquet.DataPageVersion
+ codec compress.Compression
+ }{
+ {"v1-zstd", parquet.DataPageV1, compress.Codecs.Zstd},
+ {"v2-gzip", parquet.DataPageV2, compress.Codecs.Gzip},
+ }
+ for _, tc := range cases {
+ t.Run(tc.name, func(t *testing.T) {
+ data := writeByteArrayStream(t, values, defLevels, nil,
parquet.Repetitions.Optional, tc.ver, tc.codec, 1024)
+
+ mDef, mVals := readNullableStreamColumn(t, data, false,
numRows)
+ sDef, sVals := readNullableStreamColumn(t, data, true,
numRows)
+
+ require.Equal(t, mDef, sDef, "def levels differ")
+ require.Equal(t, defLevels, sDef, "streamed def levels
wrong")
+ require.Equal(t, len(values), len(sVals))
+ for i := range values {
+ require.Truef(t, bytes.Equal(mVals[i],
sVals[i]), "value %d differs streaming vs materialized", i)
+ require.Truef(t, bytes.Equal(values[i],
sVals[i]), "streamed value %d wrong", i)
+ }
+ })
+ }
+}
+
+func readStreamTestColumnSkip(t *testing.T, data []byte, streaming bool, skip
int64) []parquet.ByteArray {
+ t.Helper()
+ props := parquet.NewReaderProperties(memory.DefaultAllocator)
+ props.PageStreamingEnabled = streaming
+
+ rdr, err := file.NewParquetReader(bytes.NewReader(data),
file.WithReadProps(props))
+ require.NoError(t, err)
+ defer rdr.Close()
+
+ cr, err := rdr.RowGroup(0).Column(0)
+ require.NoError(t, err)
+ bar := cr.(*file.ByteArrayColumnChunkReader)
+
+ skipped, err := bar.Skip(skip)
+ require.NoError(t, err)
+ require.Equal(t, skip, skipped)
+
+ return collectByteArray(t, bar, int(rdr.NumRows())-int(skip))
+}
+
+func TestPageStreamingByteArraySkip(t *testing.T) {
+ // several small values share a page so the skip discards within a
streaming page,
+ // with some large values spanning pages too.
+ values := makeStreamTestValues([]int{40, 50, 60, 70, 80, 90, 5000, 100,
9000, 40, 55, 66})
+
+ cases := []struct {
+ name string
+ ver parquet.DataPageVersion
+ codec compress.Compression
+ }{
+ {"v1-zstd", parquet.DataPageV1, compress.Codecs.Zstd},
+ {"v2-gzip", parquet.DataPageV2, compress.Codecs.Gzip},
+ }
+ for _, tc := range cases {
+ t.Run(tc.name, func(t *testing.T) {
+ data := writeStreamTestColumn(t, values, tc.ver,
tc.codec)
+
+ for _, skip := range []int64{3, 8} { // 3 within page
1; 8 crosses page boundaries
+ materialized := readStreamTestColumnSkip(t,
data, false, skip)
+ streamed := readStreamTestColumnSkip(t, data,
true, skip)
+
+ require.Len(t, streamed, len(values)-int(skip))
+ for i := range streamed {
+ require.Truef(t,
bytes.Equal(values[int(skip)+i], streamed[i]), "skip %d value %d", skip, i)
+ require.Truef(t,
bytes.Equal(materialized[i], streamed[i]), "skip %d value %d streamed !=
materialized", skip, i)
+ }
+ }
+ })
+ }
+}
+
+func readRepeatedStreamColumn(t *testing.T, data []byte, streaming bool)
(defLevels, repLevels []int16, values []parquet.ByteArray) {
+ t.Helper()
+ props := parquet.NewReaderProperties(memory.DefaultAllocator)
+ props.PageStreamingEnabled = streaming
+ rdr, err := file.NewParquetReader(bytes.NewReader(data),
file.WithReadProps(props))
+ require.NoError(t, err)
+ defer rdr.Close()
+
+ cr, err := rdr.RowGroup(0).Column(0)
+ require.NoError(t, err)
+ bar := cr.(*file.ByteArrayColumnChunkReader)
+
+ const batchSize = 64
+ for {
+ vbuf := make([]parquet.ByteArray, batchSize)
+ dbuf := make([]int16, batchSize)
+ rbuf := make([]int16, batchSize)
+ total, nv, err := bar.ReadBatch(batchSize, vbuf, dbuf, rbuf)
+ require.NoError(t, err)
+ if total == 0 {
+ break
+ }
+ defLevels = append(defLevels, dbuf[:total]...)
+ repLevels = append(repLevels, rbuf[:total]...)
+ values = append(values, vbuf[:nv]...)
+ }
+ return defLevels, repLevels, values
+}
+
+func TestPageStreamingRepeated(t *testing.T) {
+ // lists: [v0 v1 v2] [v3] [v4 v5]; rep levels mark list boundaries.
+ values := makeStreamTestValues([]int{40, 5000, 60, 9000, 70, 80})
+ repLevels := []int16{0, 1, 1, 0, 0, 1}
+ defLevels := []int16{1, 1, 1, 1, 1, 1}
+
+ cases := []struct {
+ name string
+ ver parquet.DataPageVersion
+ codec compress.Compression
+ }{
+ {"v1-zstd", parquet.DataPageV1, compress.Codecs.Zstd},
+ {"v2-gzip", parquet.DataPageV2, compress.Codecs.Gzip},
+ }
+ for _, tc := range cases {
+ t.Run(tc.name, func(t *testing.T) {
+ data := writeByteArrayStream(t, values, defLevels,
repLevels, parquet.Repetitions.Repeated, tc.ver, tc.codec, 1024)
+
+ mDef, mRep, mVals := readRepeatedStreamColumn(t, data,
false)
+ sDef, sRep, sVals := readRepeatedStreamColumn(t, data,
true)
+
+ require.Equal(t, defLevels, sDef)
+ require.Equal(t, repLevels, sRep)
+ require.Equal(t, mDef, sDef)
+ require.Equal(t, mRep, sRep)
+ require.Len(t, sVals, len(values))
+ for i := range values {
+ require.Truef(t, bytes.Equal(values[i],
sVals[i]), "value %d", i)
+ require.Truef(t, bytes.Equal(mVals[i],
sVals[i]), "value %d streamed != materialized", i)
+ }
+ })
+ }
+}
+
+func readStreamTestColumnSeekAfterRead(t *testing.T, data []byte, streaming
bool, preRead int, seekRow int64) []parquet.ByteArray {
+ t.Helper()
+ props := parquet.NewReaderProperties(memory.DefaultAllocator)
+ props.PageStreamingEnabled = streaming
+
+ rdr, err := file.NewParquetReader(bytes.NewReader(data),
file.WithReadProps(props))
+ require.NoError(t, err)
+ defer rdr.Close()
+
+ cr, err := rdr.RowGroup(0).Column(0)
+ require.NoError(t, err)
+ bar := cr.(*file.ByteArrayColumnChunkReader)
+
+ // Read a few values first so a streaming page is current and partially
consumed
+ // when the seek repositions the underlying reader.
+ if preRead > 0 {
+ pre := make([]parquet.ByteArray, preRead)
+ _, _, err := bar.ReadBatch(int64(preRead), pre, nil, nil)
+ require.NoError(t, err)
+ }
+ require.NoError(t, bar.SeekToRow(seekRow))
+
+ return collectByteArray(t, bar, int(rdr.NumRows())-int(seekRow))
+}
+
+func TestPageStreamingSeekAfterRead(t *testing.T) {
+ values := makeStreamTestValues([]int{40, 50, 60, 70, 5000, 90, 9000,
40, 55, 66, 77, 88})
+ const (
+ preRead = 2
+ seekRow = int64(8)
+ )
+
+ cases := []struct {
+ name string
+ ver parquet.DataPageVersion
+ codec compress.Compression
+ }{
+ {"v1-zstd", parquet.DataPageV1, compress.Codecs.Zstd},
+ {"v2-gzip", parquet.DataPageV2, compress.Codecs.Gzip},
+ }
+ for _, tc := range cases {
+ t.Run(tc.name, func(t *testing.T) {
+ data := writeStreamTestColumn(t, values, tc.ver,
tc.codec)
+
+ mat := readStreamTestColumnSeekAfterRead(t, data,
false, preRead, seekRow)
+ str := readStreamTestColumnSeekAfterRead(t, data, true,
preRead, seekRow)
+
+ require.Len(t, str, len(values)-int(seekRow))
+ for i := range str {
+ require.Truef(t,
bytes.Equal(values[int(seekRow)+i], str[i]), "value %d after seek", i)
+ require.Truef(t, bytes.Equal(mat[i], str[i]),
"value %d streamed != materialized", i)
+ }
+ })
+ }
+}
+
+// Large pages (> stream buffer) partially read leave undrained bytes at
SeekToRow —
+// the case that actually triggers the seek drain fix (unlike the small-page
test).
+func TestPageStreamingSeekLargePagePartial(t *testing.T) {
+ sizes := make([]int, 40)
+ for i := range sizes {
+ sizes[i] = 64 << 10 // 64 KiB -> ~16 values per >1 MiB page
+ }
+ values := makeStreamTestValues(sizes)
+ data := writeByteArrayStream(t, values, nil, nil,
parquet.Repetitions.Required, parquet.DataPageV1, compress.Codecs.Uncompressed,
1<<20)
+
+ const (
+ preRead = 20 // into page 2
+ seekRow = int64(35)
+ )
+ mat := readStreamTestColumnSeekAfterRead(t, data, false, preRead,
seekRow)
+ str := readStreamTestColumnSeekAfterRead(t, data, true, preRead,
seekRow)
+
+ require.Len(t, str, len(values)-int(seekRow))
+ for i := range str {
+ require.Truef(t, bytes.Equal(values[int(seekRow)+i], str[i]),
"value %d after seek", i)
+ require.Truef(t, bytes.Equal(mat[i], str[i]), "value %d
streamed != materialized", i)
+ }
+}
+
+func TestPageStreamingPageReaderCloseFreesStream(t *testing.T) {
+ values := makeStreamTestValues([]int{65, 5000, 200})
+ data := writeStreamTestColumn(t, values, parquet.DataPageV1,
compress.Codecs.Zstd)
+
+ checked := memory.NewCheckedAllocator(memory.DefaultAllocator)
+ defer checked.AssertSize(t, 0)
+
+ props := parquet.NewReaderProperties(checked)
+ props.PageStreamingEnabled = true
+ rdr, err := file.NewParquetReader(bytes.NewReader(data),
file.WithReadProps(props))
+ require.NoError(t, err)
+ defer rdr.Close()
+
+ pr, err := rdr.RowGroup(0).GetColumnPageReader(0)
+ require.NoError(t, err)
+ require.True(t, pr.Next()) // advance to a streaming data page
+ require.NoError(t, pr.Close())
+}
+
+func makeFLBAValues(count, width int) []parquet.FixedLenByteArray {
+ vals := make([]parquet.FixedLenByteArray, count)
+ for i := range vals {
+ b := make([]byte, width)
+ b[0] = byte(i)
+ for j := 1; j < width; j++ {
+ b[j] = byte(i*13 + j)
+ }
+ vals[i] = b
+ }
+ return vals
+}
+
+func writeFLBAStreamColumn(t *testing.T, values []parquet.FixedLenByteArray,
width int, ver parquet.DataPageVersion, codec compress.Compression) []byte {
+ t.Helper()
+ sc := schema.NewSchema(schema.MustGroup(schema.NewGroupNode("schema",
parquet.Repetitions.Required, schema.FieldList{
+ schema.Must(schema.NewPrimitiveNode("data",
parquet.Repetitions.Required, parquet.Types.FixedLenByteArray, -1,
int32(width))),
+ }, -1)))
+ props := parquet.NewWriterProperties(
+ parquet.WithDictionaryDefault(false),
+ parquet.WithDataPageVersion(ver),
+ parquet.WithCompression(codec),
+ parquet.WithDataPageSize(1024),
+ )
+ out := &bytes.Buffer{}
+ w := file.NewParquetWriter(out, sc.Root(), file.WithWriterProps(props))
+ rgw := w.AppendRowGroup()
+ cw, err := rgw.NextColumn()
+ require.NoError(t, err)
+ _, err =
cw.(*file.FixedLenByteArrayColumnChunkWriter).WriteBatch(values, nil, nil)
+ require.NoError(t, err)
+ require.NoError(t, cw.Close())
+ require.NoError(t, rgw.Close())
+ require.NoError(t, w.Close())
+ return out.Bytes()
+}
+
+func readFLBAStreamColumnSkip(t *testing.T, data []byte, streaming bool, skip
int64) []parquet.FixedLenByteArray {
+ t.Helper()
+ props := parquet.NewReaderProperties(memory.DefaultAllocator)
+ props.PageStreamingEnabled = streaming
+ rdr, err := file.NewParquetReader(bytes.NewReader(data),
file.WithReadProps(props))
+ require.NoError(t, err)
+ defer rdr.Close()
+
+ cr, err := rdr.RowGroup(0).Column(0)
+ require.NoError(t, err)
+ bar := cr.(*file.FixedLenByteArrayColumnChunkReader)
+ if skip > 0 {
+ skipped, err := bar.Skip(skip)
+ require.NoError(t, err)
+ require.Equal(t, skip, skipped)
+ }
+ n := int(rdr.NumRows()) - int(skip)
+ out := make([]parquet.FixedLenByteArray, n)
+ total := 0
+ for total < n {
+ _, nv, err := bar.ReadBatch(int64(n-total), out[total:], nil,
nil)
+ require.NoError(t, err)
+ if nv == 0 {
+ break
+ }
+ total += nv
+ }
+ require.Equal(t, n, total)
+ return out
+}
+
+func TestPageStreamingFLBASkip(t *testing.T) {
+ const width = 24
+ values := makeFLBAValues(80, width) // multiple 1 KiB pages
+
+ cases := []struct {
+ name string
+ ver parquet.DataPageVersion
+ codec compress.Compression
+ }{
+ {"v1-zstd", parquet.DataPageV1, compress.Codecs.Zstd},
+ {"v2-gzip", parquet.DataPageV2, compress.Codecs.Gzip},
+ }
+ for _, tc := range cases {
+ t.Run(tc.name, func(t *testing.T) {
+ data := writeFLBAStreamColumn(t, values, width, tc.ver,
tc.codec)
+
+ full := readFLBAStreamColumnSkip(t, data, true, 0)
+ require.Len(t, full, len(values))
+ for i := range values {
+ require.Truef(t, bytes.Equal(values[i],
full[i]), "roundtrip value %d", i)
+ }
+
+ for _, skip := range []int64{5, 50} { // 50 crosses a
page boundary
+ mat := readFLBAStreamColumnSkip(t, data, false,
skip)
+ str := readFLBAStreamColumnSkip(t, data, true,
skip)
+ require.Len(t, str, len(values)-int(skip))
+ for i := range str {
+ require.Truef(t,
bytes.Equal(values[int(skip)+i], str[i]), "skip %d value %d", skip, i)
+ require.Truef(t, bytes.Equal(mat[i],
str[i]), "skip %d value %d streamed != materialized", skip, i)
+ }
+ }
+ })
+ }
+}
+
+// TestPageStreamingPeakMemoryBounded is the core memory-goal check: a single
page
+// far larger than the stream buffer, read in small batches, must keep the
+// allocator footprint near the buffer, not grow to the whole page.
+func TestPageStreamingPeakMemoryBounded(t *testing.T) {
+ sizes := make([]int, 2000)
+ for i := range sizes {
+ sizes[i] = 2048 // ~4 MiB of values in one page
+ }
+ values := makeStreamTestValues(sizes)
+ data := writeByteArrayStream(t, values, nil, nil,
parquet.Repetitions.Required, parquet.DataPageV1, compress.Codecs.Zstd, 8<<20)
+
+ checked := memory.NewCheckedAllocator(memory.DefaultAllocator)
+ defer checked.AssertSize(t, 0)
+ props := parquet.NewReaderProperties(checked)
+ props.PageStreamingEnabled = true
+ rdr, err := file.NewParquetReader(bytes.NewReader(data),
file.WithReadProps(props))
+ require.NoError(t, err)
+ defer rdr.Close()
+
+ cr, err := rdr.RowGroup(0).Column(0)
+ require.NoError(t, err)
+ bar := cr.(*file.ByteArrayColumnChunkReader)
+
+ const batch = 64
+ out := make([]parquet.ByteArray, batch)
+ peak, total := 0, 0
+ for {
+ _, nv, err := bar.ReadBatch(batch, out, nil, nil)
+ require.NoError(t, err)
+ if nv == 0 {
+ break
+ }
+ total += nv
+ if a := checked.CurrentAlloc(); a > peak {
+ peak = a
+ }
+ }
+ require.NoError(t, bar.Close())
+ require.Equal(t, len(values), total)
+
+ t.Logf("peak allocator use: %d bytes (page ~%d bytes)", peak,
len(values)*2048)
+ require.Less(t, peak, 2<<20, "streaming peak should stay near the
buffer, not the page")
+}
+
+func readInPage(t *testing.T, data []byte, batch int64) (defLevels, repLevels
[]int16, values []parquet.ByteArray) {
+ t.Helper()
+ props := parquet.NewReaderProperties(memory.DefaultAllocator)
+ props.PageStreamingEnabled = true
+ rdr, err := file.NewParquetReader(bytes.NewReader(data),
file.WithReadProps(props))
+ require.NoError(t, err)
+ defer rdr.Close()
+
+ cr, err := rdr.RowGroup(0).Column(0)
+ require.NoError(t, err)
+ bar := cr.(*file.ByteArrayColumnChunkReader)
+
+ dbuf := make([]int16, batch)
+ rbuf := make([]int16, batch)
+ vbuf := make([]parquet.ByteArray, batch)
+ for {
+ total, nv, err := bar.ReadBatchInPage(batch, vbuf, dbuf, rbuf)
+ require.NoError(t, err)
+ if total == 0 {
+ break
+ }
+ defLevels = append(defLevels, dbuf[:total]...)
+ repLevels = append(repLevels, rbuf[:total]...)
+ for i := 0; i < nv; i++ {
+ values = append(values, append([]byte(nil),
vbuf[i]...)) // clone the alias
+ }
+ }
+ return defLevels, repLevels, values
+}
+
+// Large values force per-page batch clipping (multiple Decodes) on a nullable
column.
+func TestPageStreamingReadBatchInPageNullable(t *testing.T) {
+ const numRows = 60
+ defLevels := make([]int16, numRows)
+ var values []parquet.ByteArray
+ for i := 0; i < numRows; i++ {
+ if i%3 == 0 {
+ continue // null
+ }
+ defLevels[i] = 1
+ sz := 80 << 10 // 80 KiB: several per 1 MiB window, so
DecodeStreaming returns short
+ b := make([]byte, sz)
+ b[0] = byte(i)
+ for j := 1; j < sz; j++ {
+ b[j] = byte(i*7 + j)
+ }
+ values = append(values, b)
+ }
+
+ for _, tc := range []struct {
+ name string
+ ver parquet.DataPageVersion
+ codec compress.Compression
+ }{
+ {"v1-zstd", parquet.DataPageV1, compress.Codecs.Zstd},
+ {"v2-gzip", parquet.DataPageV2, compress.Codecs.Gzip},
+ } {
+ t.Run(tc.name, func(t *testing.T) {
+ data := writeByteArrayStream(t, values, defLevels, nil,
parquet.Repetitions.Optional, tc.ver, tc.codec, 8<<20)
+ for _, batch := range []int64{7, 64} {
+ gotDef, _, gotVals := readInPage(t, data, batch)
+ require.Equalf(t, defLevels, gotDef, "batch %d
def levels", batch)
+ require.Lenf(t, gotVals, len(values), "batch %d
value count", batch)
+ for i := range values {
+ require.Truef(t, bytes.Equal(values[i],
gotVals[i]), "batch %d value %d", batch, i)
+ }
+ }
+ })
+ }
+}
+
+func TestPageStreamingReadBatchInPageRequired(t *testing.T) {
+ sizes := make([]int, 40)
+ for i := range sizes {
+ sizes[i] = 80 << 10 // large, so DecodeStreaming returns short
against the window
+ }
+ values := makeStreamTestValues(sizes)
+ data := writeByteArrayStream(t, values, nil, nil,
parquet.Repetitions.Required, parquet.DataPageV1, compress.Codecs.Zstd, 8<<20)
+
+ for _, batch := range []int64{5, 64} {
+ _, _, got := readInPage(t, data, batch)
+ require.Lenf(t, got, len(values), "batch %d value count", batch)
+ for i := range values {
+ require.Truef(t, bytes.Equal(values[i], got[i]), "batch
%d value %d", batch, i)
+ }
+ }
+}
+
+func TestPageStreamingReadBatchInPageRepeated(t *testing.T) {
+ sizes := make([]int, 30)
+ for i := range sizes {
+ sizes[i] = 80 << 10
+ }
+ values := makeStreamTestValues(sizes)
+ defLevels := make([]int16, len(values))
+ repLevels := make([]int16, len(values))
+ for i := range values {
+ defLevels[i] = 1
+ if i%3 != 0 {
+ repLevels[i] = 1 // 0 starts a new list, 1 continues it
+ }
+ }
+ data := writeByteArrayStream(t, values, defLevels, repLevels,
parquet.Repetitions.Repeated, parquet.DataPageV1, compress.Codecs.Zstd, 8<<20)
+
+ for _, batch := range []int64{7, 64} {
+ gotDef, gotRep, gotVals := readInPage(t, data, batch)
+ require.Equalf(t, defLevels, gotDef, "batch %d def levels",
batch)
+ require.Equalf(t, repLevels, gotRep, "batch %d rep levels",
batch)
+ require.Lenf(t, gotVals, len(values), "batch %d value count",
batch)
+ for i := range values {
+ require.Truef(t, bytes.Equal(values[i], gotVals[i]),
"batch %d value %d", batch, i)
+ }
+ }
+}
+
+// TestPageStreamingThreshold verifies streamingThreshold gates streaming: a
page below
+// the threshold falls back to a whole-page read.
+func TestPageStreamingThreshold(t *testing.T) {
+ values := makeStreamTestValues([]int{65, 5000, 200}) // small page,
well under 1 MiB
+ data := writeStreamTestColumn(t, values, parquet.DataPageV1,
compress.Codecs.Zstd)
+
+ firstPageDataLen := func(threshold int64) int {
+ defer func(old int64) { *file.StreamingThreshold = old
}(*file.StreamingThreshold)
+ *file.StreamingThreshold = threshold
+
+ props := parquet.NewReaderProperties(memory.DefaultAllocator)
+ props.PageStreamingEnabled = true
+ rdr, err := file.NewParquetReader(bytes.NewReader(data),
file.WithReadProps(props))
+ require.NoError(t, err)
+ defer rdr.Close()
+
+ pr, err := rdr.RowGroup(0).GetColumnPageReader(0)
+ require.NoError(t, err)
+ defer pr.Close()
+ require.True(t, pr.Next())
+ return len(pr.Page().Data())
+ }
+
+ require.NotZero(t, firstPageDataLen(1<<20), "small page must not stream
at the 1 MiB threshold")
+ require.Zero(t, firstPageDataLen(0), "with threshold 0 the small page
streams (empty level region)")
+}
+
+// TestPageStreamingRecordReader drives the record reader (the pqarrow read
path) over a
+// streaming column in small batches, so a Decode's aliased values must
survive until the
+// builder copies them and be reclaimed by the next Decode's Recycle. Large
values force
+// chunk rotation, so a broken alias contract would corrupt the output.
+func TestPageStreamingRecordReader(t *testing.T) {
+ cases := []struct {
+ name string
+ rep parquet.Repetition
+ defLevel int16
+ }{
+ {"required", parquet.Repetitions.Required, 0},
+ {"optional", parquet.Repetitions.Optional, 1},
+ }
+ for _, tc := range cases {
+ t.Run(tc.name, func(t *testing.T) {
+ const nrows = 300
+ var (
+ sizes []int
+ defLevels []int16
+ wantNull []bool
+ )
+ for i := 0; i < nrows; i++ {
+ if tc.defLevel == 1 && i%5 == 0 {
+ defLevels = append(defLevels, 0)
+ wantNull = append(wantNull, true)
+ continue
+ }
+ sizes = append(sizes, 3000) // large enough to
force chunk rotation
+ defLevels = append(defLevels, tc.defLevel)
+ wantNull = append(wantNull, false)
+ }
+ values := makeStreamTestValues(sizes)
+ var dl []int16
+ if tc.defLevel == 1 {
+ dl = defLevels
+ }
+ data := writeByteArrayStream(t, values, dl, nil,
tc.rep, parquet.DataPageV1, compress.Codecs.Zstd, 8<<20)
+
+ props :=
parquet.NewReaderProperties(memory.DefaultAllocator)
+ props.PageStreamingEnabled = true
+ rdr, err :=
file.NewParquetReader(bytes.NewReader(data), file.WithReadProps(props))
+ require.NoError(t, err)
+ defer rdr.Close()
+
+ pr, err := rdr.RowGroup(0).GetColumnPageReader(0)
+ require.NoError(t, err)
+ descr := rdr.MetaData().Schema.Column(0)
+
+ rr := file.NewRecordReader(descr,
file.LevelInfo{NullSlotUsage: 1, DefLevel: tc.defLevel},
arrow.BinaryTypes.Binary, memory.DefaultAllocator, rdr.BufferPool())
+ defer rr.Release()
+ rr.SetPageReader(pr)
+ rr.Reset()
+ require.NoError(t, rr.Reserve(nrows))
+ for rr.HasMore() {
+ read, err := rr.ReadRecords(37) // small batch
=> many Decode/Recycle boundaries
+ require.NoError(t, err)
+ if read == 0 {
+ break
+ }
+ }
+
+ var got []parquet.ByteArray
+ var gotNull []bool
+ for _, chunk := range
rr.(file.BinaryRecordReader).GetBuilderChunks() {
+ bin := chunk.(*array.Binary)
+ for i := 0; i < bin.Len(); i++ {
+ gotNull = append(gotNull, bin.IsNull(i))
+ got = append(got, bin.Value(i))
+ }
+ chunk.Release()
+ }
+
+ require.Equal(t, nrows, len(got))
+ require.Equal(t, wantNull, gotNull)
+ vi := 0
+ for i := 0; i < nrows; i++ {
+ if wantNull[i] {
+ continue
+ }
+ require.Truef(t, bytes.Equal(values[vi],
got[i]), "value at row %d corrupted", i)
+ vi++
+ }
+ })
+ }
+}
diff --git a/parquet/internal/encoding/byte_array_decoder.go
b/parquet/internal/encoding/byte_array_decoder.go
index e568ef92..10112b07 100644
--- a/parquet/internal/encoding/byte_array_decoder.go
+++ b/parquet/internal/encoding/byte_array_decoder.go
@@ -19,12 +19,14 @@ package encoding
import (
"encoding/binary"
"errors"
+ "fmt"
"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/internal/utils"
"github.com/apache/arrow-go/v18/parquet"
+ "github.com/apache/arrow-go/v18/parquet/internal/encoding/streaming"
pqutils "github.com/apache/arrow-go/v18/parquet/internal/utils"
)
@@ -37,6 +39,9 @@ import (
// bytes being the raw data of the byte array.
type PlainByteArrayDecoder struct {
decoder
+
+ // src drives the streaming path (PageStreamingEnabled) when non-nil.
+ src streaming.ValueBuffer
}
// Type returns parquet.Types.ByteArray for this decoder
@@ -44,7 +49,70 @@ func (PlainByteArrayDecoder) Type() parquet.Type {
return parquet.Types.ByteArray
}
+func (pbad *PlainByteArrayDecoder) SetSource(nvals int, src
streaming.ValueBuffer) {
+ pbad.src, pbad.nvals = src, nvals
+}
+
+func (pbad *PlainByteArrayDecoder) SetData(nvals int, data []byte) error {
+ pbad.src = nil
+ return pbad.decoder.SetData(nvals, data)
+}
+
+// decodeStreaming fills out by aliasing each value in the buffer (no copy).
Recycle at
+// entry reclaims the previous batch, so the returned slices are valid only
until the
+// next Decode call; callers consume or copy them before then.
+func (pbad *PlainByteArrayDecoder) decodeStreaming(out []parquet.ByteArray)
(int, error) {
+ pbad.src.Recycle()
+ max := utils.Min(len(out), pbad.nvals)
+ for i := 0; i < max; i++ {
+ hdr, err := pbad.src.Fill(4)
+ if err != nil {
+ return i, fmt.Errorf("parquet: reading bytearray: %w",
err)
+ }
+ byteLen := int(int32(binary.LittleEndian.Uint32(hdr[:4])))
+ if byteLen < 0 {
+ return i, errors.New("parquet: invalid BYTE_ARRAY
value")
+ }
+ pbad.src.Advance(4)
+ // Fill rejects a byteLen past the value region before
allocating.
+ val, err := pbad.src.Fill(byteLen)
+ if err != nil {
+ return i, fmt.Errorf("parquet: reading bytearray: %w",
err)
+ }
+ // 3-index cap stops appends bleeding into the next value; a
zero-length slice
+ // of the non-nil buffer stays non-nil (empty value != null).
+ out[i] = val[:byteLen:byteLen]
+ pbad.src.Advance(byteLen)
+ }
+ pbad.nvals -= max
+ return max, nil
+}
+
+func (pbad *PlainByteArrayDecoder) discardStreaming(n int) (int, error) {
+ n = min(n, pbad.nvals)
+ for i := 0; i < n; i++ {
+ pbad.src.Recycle()
+ buf, err := pbad.src.Fill(4)
+ if err != nil {
+ return i, fmt.Errorf("parquet: skipping bytearray: %w",
err)
+ }
+ valueLen := int(int32(binary.LittleEndian.Uint32(buf[:4])))
+ if valueLen < 0 {
+ return i, errors.New("parquet: invalid BYTE_ARRAY
value")
+ }
+ pbad.src.Advance(4)
+ if err := pbad.src.Skip(valueLen); err != nil {
+ return i, fmt.Errorf("parquet: skipping bytearray: %w",
err)
+ }
+ }
+ pbad.nvals -= n
+ return n, nil
+}
+
func (pbad *PlainByteArrayDecoder) Discard(n int) (int, error) {
+ if pbad.src != nil {
+ return pbad.discardStreaming(n)
+ }
n = min(n, pbad.nvals)
// we have to skip the length of each value by first checking
// the length of the value and then skipping that many bytes
@@ -74,6 +142,10 @@ func (pbad *PlainByteArrayDecoder) Discard(n int) (int,
error) {
//
// Returns the number of values that were decoded.
func (pbad *PlainByteArrayDecoder) Decode(out []parquet.ByteArray) (int,
error) {
+ if pbad.src != nil {
+ return pbad.decodeStreaming(out)
+ }
+
max := utils.Min(len(out), pbad.nvals)
for i := 0; i < max; i++ {
diff --git a/parquet/internal/encoding/fixed_len_byte_array_decoder.go
b/parquet/internal/encoding/fixed_len_byte_array_decoder.go
index 8b197e7b..89c47632 100644
--- a/parquet/internal/encoding/fixed_len_byte_array_decoder.go
+++ b/parquet/internal/encoding/fixed_len_byte_array_decoder.go
@@ -23,11 +23,15 @@ import (
"github.com/apache/arrow-go/v18/internal/utils"
"github.com/apache/arrow-go/v18/parquet"
+ "github.com/apache/arrow-go/v18/parquet/internal/encoding/streaming"
)
// PlainFixedLenByteArrayDecoder is a plain encoding decoder for Fixed Length
Byte Arrays
type PlainFixedLenByteArrayDecoder struct {
decoder
+
+ // src drives the streaming path (PageStreamingEnabled) when non-nil.
+ src streaming.ValueBuffer
}
// Type returns the physical type this decoder operates on, FixedLength Byte
Arrays
@@ -35,7 +39,47 @@ func (PlainFixedLenByteArrayDecoder) Type() parquet.Type {
return parquet.Types.FixedLenByteArray
}
+func (pflba *PlainFixedLenByteArrayDecoder) SetSource(nvals int, src
streaming.ValueBuffer) {
+ pflba.src, pflba.nvals = src, nvals
+}
+
+func (pflba *PlainFixedLenByteArrayDecoder) SetData(nvals int, data []byte)
error {
+ pflba.src = nil
+ return pflba.decoder.SetData(nvals, data)
+}
+
+// decodeStreaming aliases each value in the buffer; see the byte-array
decoder for the
+// Recycle/alias lifetime.
+func (pflba *PlainFixedLenByteArrayDecoder) decodeStreaming(out
[]parquet.FixedLenByteArray) (int, error) {
+ pflba.src.Recycle()
+ max := utils.Min(len(out), pflba.nvals)
+ w := pflba.typeLen
+ for i := 0; i < max; i++ {
+ val, err := pflba.src.Fill(w)
+ if err != nil {
+ return i, errors.New("parquet: eof exception")
+ }
+ out[i] = val[:w:w]
+ pflba.src.Advance(w)
+ }
+ pflba.nvals -= max
+ return max, nil
+}
+
+func (pflba *PlainFixedLenByteArrayDecoder) discardStreaming(n int) (int,
error) {
+ n = min(n, pflba.nvals)
+ pflba.src.Recycle()
+ if err := pflba.src.Skip(n * pflba.typeLen); err != nil {
+ return 0, errors.New("parquet: eof exception")
+ }
+ pflba.nvals -= n
+ return n, nil
+}
+
func (pflba *PlainFixedLenByteArrayDecoder) Discard(n int) (int, error) {
+ if pflba.src != nil {
+ return pflba.discardStreaming(n)
+ }
n = min(n, pflba.nvals)
numBytesNeeded := n * pflba.typeLen
if numBytesNeeded > len(pflba.data) || numBytesNeeded > math.MaxInt32 {
@@ -51,6 +95,10 @@ func (pflba *PlainFixedLenByteArrayDecoder) Discard(n int)
(int, error) {
// values to decode or the length of out has been filled. Then returns the
total number of values
// that were decoded.
func (pflba *PlainFixedLenByteArrayDecoder) Decode(out
[]parquet.FixedLenByteArray) (int, error) {
+ if pflba.src != nil {
+ return pflba.decodeStreaming(out)
+ }
+
max := utils.Min(len(out), pflba.nvals)
numBytesNeeded := max * pflba.typeLen
if numBytesNeeded > len(pflba.data) || numBytesNeeded > math.MaxInt32 {
diff --git a/parquet/internal/encoding/streaming/streaming_test.go
b/parquet/internal/encoding/streaming/streaming_test.go
new file mode 100644
index 00000000..c2b7ae2b
--- /dev/null
+++ b/parquet/internal/encoding/streaming/streaming_test.go
@@ -0,0 +1,70 @@
+// 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 streaming_test
+
+import (
+ "bytes"
+ "io"
+ "testing"
+
+ "github.com/apache/arrow-go/v18/arrow/memory"
+ "github.com/apache/arrow-go/v18/parquet/internal/encoding/streaming"
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+func TestStreamBuffer(t *testing.T) {
+ data := []byte{0, 1, 2, 3, 4, 5, 6, 7, 8, 9}
+ vb := streaming.NewStreamBuffer(memory.DefaultAllocator,
bytes.NewReader(data), len(data), nil)
+
+ buf, err := vb.Fill(4)
+ require.NoError(t, err)
+ require.GreaterOrEqual(t, len(buf), 4)
+ assert.Equal(t, []byte{0, 1, 2, 3}, buf[:4])
+
+ vb.Advance(4)
+ buf, err = vb.Fill(4)
+ require.NoError(t, err)
+ require.GreaterOrEqual(t, len(buf), 4)
+ assert.Equal(t, []byte{4, 5, 6, 7}, buf[:4])
+
+ vb.Advance(4)
+ _, err = vb.Fill(4)
+ assert.ErrorIs(t, err, io.ErrUnexpectedEOF)
+}
+
+func TestClipBatch(t *testing.T) {
+ const buf = streaming.DefaultBufferSize
+ // Value region larger than the buffer: clip so rows*avgWidth stays
near the buffer.
+ assert.Equal(t, int64(buf/1024), streaming.ClipBatch(4*buf, 4096)) //
avg 1024 bytes
+ assert.Equal(t, int64(1), streaming.ClipBatch(4*buf, 2)) //
huge values: one per decode
+ // Whole region fits one buffer, or no values: no clip (returns nvals).
+ assert.Equal(t, int64(1_000_000), streaming.ClipBatch(buf/2,
1_000_000)) // small values
+ assert.Equal(t, int64(1_000_000), streaming.ClipBatch(0, 1_000_000))
// all-null page
+ assert.Equal(t, int64(0), streaming.ClipBatch(0, 0))
+}
+
+func TestStreamBufferGrowsForOversizedValue(t *testing.T) {
+ data := bytes.Repeat([]byte{0xAB}, (1<<20)+1024) // larger than the
default buffer
+ vb := streaming.NewStreamBuffer(memory.DefaultAllocator,
bytes.NewReader(data), len(data), nil)
+
+ buf, err := vb.Fill(len(data))
+ require.NoError(t, err)
+ require.GreaterOrEqual(t, len(buf), len(data))
+ assert.Equal(t, data, buf[:len(data)])
+ require.NoError(t, vb.Close())
+}
diff --git a/parquet/internal/encoding/streaming/value_buffer.go
b/parquet/internal/encoding/streaming/value_buffer.go
new file mode 100644
index 00000000..4bfc7875
--- /dev/null
+++ b/parquet/internal/encoding/streaming/value_buffer.go
@@ -0,0 +1,174 @@
+// 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 streaming
+
+import (
+ "io"
+
+ "github.com/apache/arrow-go/v18/arrow/memory"
+)
+
+// ValueBuffer is the byte source a streaming decoder reads; bytes from Fill
stay valid
+// until the next Recycle, so a decoder can alias them instead of copying.
+type ValueBuffer interface {
+ // Fill returns >= need contiguous bytes from the cursor, valid until
the next Recycle;
+ // io.ErrUnexpectedEOF if fewer remain (incl. a need past the value
region).
+ Fill(need int) ([]byte, error)
+ // Advance marks the first n bytes from the cursor as consumed.
+ Advance(n int)
+ // Skip discards the next n bytes without buffering them, for the
discard/seek path.
+ Skip(n int) error
+ // Recycle reclaims everything handed out since the last Recycle; the
caller must be
+ // done with the previous batch's aliases. A decoder calls it when
entering Decode.
+ Recycle()
+ io.Closer
+}
+
+// Decoder is a decoder that can read from a ValueBuffer via SetSource.
+type Decoder interface {
+ SetSource(nvals int, src ValueBuffer)
+}
+
+// DefaultBufferSize is the steady-state chunk size and the clip target.
+const DefaultBufferSize = 1 << 20
+
+// streamBuffer serves the value stream from reusable chunks; aliases stay
valid until Recycle.
+type streamBuffer struct {
+ r io.Reader
+ onClose func() error
+ mem memory.Allocator
+ chunkSize int
+ cur []byte // chunk currently being filled (off == n on Fill
entry)
+ off, n int // consumed cursor / filled end within cur
+ live [][]byte // earlier chunks still backing this Decode's aliases
+ remaining int // value-region bytes not yet consumed; bounds Fill
vs corrupt lengths
+}
+
+// NewStreamBuffer returns a ValueBuffer over r, bounded to valueBytes (the
value region).
+func NewStreamBuffer(mem memory.Allocator, r io.Reader, valueBytes int,
onClose func() error) ValueBuffer {
+ size := DefaultBufferSize
+ if valueBytes > 0 && valueBytes < size {
+ size = valueBytes
+ }
+ return &streamBuffer{mem: mem, r: r, onClose: onClose, chunkSize: size,
cur: mem.Allocate(size), remaining: valueBytes}
+}
+
+// ClipBatch estimates rows per Decode that keep a batch's aliased values near
the buffer
+// size, for a page whose value region exceeds it. Row count is a conservative
proxy for
+// value count; skewed sizes may overshoot. Returns nvals (no clip) when the
whole region
+// already fits one buffer.
+func ClipBatch(valueBytes, nvals int) int64 {
+ if valueBytes <= DefaultBufferSize || nvals <= 0 {
+ return int64(nvals)
+ }
+ avg := valueBytes / nvals
+ if avg < 1 {
+ avg = 1
+ }
+ if clip := int64(DefaultBufferSize / avg); clip > 1 {
+ return clip
+ }
+ return 1
+}
+
+func (s *streamBuffer) Advance(n int) {
+ s.off += n
+ s.remaining -= n
+}
+
+func (s *streamBuffer) Skip(n int) error {
+ if n > s.remaining {
+ return io.ErrUnexpectedEOF
+ }
+ s.remaining -= n
+ buffered := s.n - s.off
+ if buffered >= n {
+ s.off += n
+ return nil
+ }
+ n -= buffered
+ // Nothing here is aliased, so drop the cursor and discard the rest
from the reader.
+ s.off, s.n = 0, 0
+ _, err := io.CopyN(io.Discard, s.r, int64(n))
+ return err
+}
+
+func (s *streamBuffer) Fill(need int) ([]byte, error) {
+ if s.n-s.off >= need {
+ return s.cur[s.off:s.n], nil
+ }
+ if need > s.remaining {
+ return nil, io.ErrUnexpectedEOF
+ }
+ if need > len(s.cur)-s.off {
+ s.rotate(need)
+ }
+ for s.n-s.off < need {
+ m, err := s.r.Read(s.cur[s.n : s.off+need])
+ s.n += m
+ if err != nil {
+ if err == io.EOF {
+ break
+ }
+ return nil, err
+ }
+ }
+ if s.n-s.off < need {
+ return nil, io.ErrUnexpectedEOF
+ }
+ return s.cur[s.off:s.n], nil
+}
+
+// rotate retires the full chunk (still backing aliases) and starts fresh,
carrying any unconsumed tail.
+func (s *streamBuffer) rotate(need int) {
+ tail := s.n - s.off
+ s.live = append(s.live, s.cur)
+ size := s.chunkSize
+ if need > size {
+ size = need
+ }
+ nc := s.mem.Allocate(size)
+ copy(nc, s.cur[s.off:s.n])
+ s.cur, s.off, s.n = nc, 0, tail
+}
+
+func (s *streamBuffer) Recycle() {
+ for _, b := range s.live {
+ s.mem.Free(b)
+ }
+ s.live = s.live[:0]
+ // Rewind the primary chunk; swap out an oversized one so steady state
stays small.
+ if len(s.cur) != s.chunkSize {
+ s.mem.Free(s.cur)
+ s.cur = s.mem.Allocate(s.chunkSize)
+ }
+ s.off, s.n = 0, 0
+}
+
+func (s *streamBuffer) Close() error {
+ for _, b := range s.live {
+ s.mem.Free(b)
+ }
+ if s.cur != nil {
+ s.mem.Free(s.cur)
+ }
+ s.live, s.cur = nil, nil
+ if s.onClose != nil {
+ return s.onClose()
+ }
+ return nil
+}
diff --git a/parquet/internal/encoding/streaming/value_buffer_internal_test.go
b/parquet/internal/encoding/streaming/value_buffer_internal_test.go
new file mode 100644
index 00000000..b10d54a0
--- /dev/null
+++ b/parquet/internal/encoding/streaming/value_buffer_internal_test.go
@@ -0,0 +1,136 @@
+// 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 streaming
+
+import (
+ "bytes"
+ "io"
+ "testing"
+
+ "github.com/apache/arrow-go/v18/arrow/memory"
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+// newTestBuffer builds a streamBuffer with a small chunk so a few reads force
the
+// rotate/oversized paths.
+func newTestBuffer(mem memory.Allocator, data []byte, chunkSize int)
*streamBuffer {
+ return &streamBuffer{mem: mem, r: bytes.NewReader(data), chunkSize:
chunkSize, cur: mem.Allocate(chunkSize), remaining: len(data)}
+}
+
+// fillValue reads the next need bytes as a value: Fill then Advance,
returning the
+// aliased slice.
+func fillValue(t *testing.T, s *streamBuffer, need int) []byte {
+ t.Helper()
+ b, err := s.Fill(need)
+ require.NoError(t, err)
+ v := b[:need:need]
+ s.Advance(need)
+ return v
+}
+
+// TestStreamBufferAliasesStableUntilRecycle checks that values aliased across
a chunk
+// rotation stay valid until Recycle: the filled chunk is set aside, not
overwritten.
+func TestStreamBufferAliasesStableUntilRecycle(t *testing.T) {
+ data := []byte{0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11}
+ s := newTestBuffer(memory.DefaultAllocator, data, 8)
+
+ v1 := fillValue(t, s, 4) // [0..3]
+ v2 := fillValue(t, s, 4) // [4..7], fills the chunk
+ v3 := fillValue(t, s, 4) // [8..11], forces a fresh chunk
+
+ assert.Equal(t, []byte{0, 1, 2, 3}, v1, "aliased value corrupted by
rotation")
+ assert.Equal(t, []byte{4, 5, 6, 7}, v2, "aliased value corrupted by
rotation")
+ assert.Equal(t, []byte{8, 9, 10, 11}, v3)
+
+ require.NoError(t, s.Close())
+}
+
+// TestStreamBufferOversizedValue checks that a value larger than the chunk
allocates a
+// fresh chunk to hold it, and Recycle swaps back to the steady-state size.
+func TestStreamBufferOversizedValue(t *testing.T) {
+ big := bytes.Repeat([]byte{0xAB}, 20)
+ data := append([]byte{1, 2, 3, 4}, big...)
+ s := newTestBuffer(memory.DefaultAllocator, data, 8)
+
+ assert.Equal(t, []byte{1, 2, 3, 4}, fillValue(t, s, 4))
+ assert.Equal(t, big, fillValue(t, s, 20))
+ assert.Greater(t, len(s.cur), 8, "buffer should grow for the oversized
value")
+
+ s.Recycle()
+ assert.Equal(t, 8, len(s.cur), "buffer should shrink back to the
steady-state size")
+
+ require.NoError(t, s.Close())
+}
+
+// TestStreamBufferSkip checks Skip discards both buffered and not-yet-read
bytes.
+func TestStreamBufferSkip(t *testing.T) {
+ data := []byte{0, 1, 2, 3, 4, 5, 6, 7, 8, 9}
+ s := newTestBuffer(memory.DefaultAllocator, data, 8)
+
+ assert.Equal(t, []byte{0, 1}, fillValue(t, s, 2))
+ require.NoError(t, s.Skip(6)) // 2 buffered + 4 straight from the reader
+ assert.Equal(t, []byte{8, 9}, fillValue(t, s, 2))
+
+ require.NoError(t, s.Close())
+}
+
+// TestStreamBufferRejectsOversizedNeed checks a need past the value region (a
corrupt
+// length prefix) is rejected before allocating.
+func TestStreamBufferRejectsOversizedNeed(t *testing.T) {
+ checked := memory.NewCheckedAllocator(memory.DefaultAllocator)
+ s := newTestBuffer(checked, make([]byte, 10), 8)
+
+ _, err := s.Fill(1 << 30)
+ require.ErrorIs(t, err, io.ErrUnexpectedEOF)
+
+ require.NoError(t, s.Close())
+ checked.AssertSize(t, 0) // the 1 GiB was never allocated
+}
+
+// TestStreamBufferRecycleReclaims checks that repeated Recycle keeps memory
bounded and
+// frees the extra chunks a rotation created, so nothing leaks across Decodes.
+func TestStreamBufferRecycleReclaims(t *testing.T) {
+ checked := memory.NewCheckedAllocator(memory.DefaultAllocator)
+ s := newTestBuffer(checked, bytes.Repeat([]byte{1}, 1000), 8)
+
+ for i := 0; i < 50; i++ {
+ fillValue(t, s, 5) // off=5
+ fillValue(t, s, 5) // forces a rotation: old chunk goes to live
+ require.NotEmpty(t, s.live)
+ s.Recycle() // must free the retired chunk
+ require.Empty(t, s.live)
+ }
+
+ require.NoError(t, s.Close())
+ checked.AssertSize(t, 0)
+}
+
+func TestStreamBufferRotateCarriesTail(t *testing.T) {
+ data := []byte{0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15}
+ s := newTestBuffer(memory.DefaultAllocator, data, 8)
+ defer s.Close()
+
+ b, err := s.Fill(6)
+ require.NoError(t, err)
+ assert.Equal(t, data[:6], b[:6])
+ s.Advance(2) // leave an unconsumed tail so the next Fill rotates with
off != n
+
+ b, err = s.Fill(7)
+ require.NoError(t, err)
+ assert.Equal(t, data[2:9], b[:7])
+}
diff --git a/parquet/internal/encoding/streaming_decoder_test.go
b/parquet/internal/encoding/streaming_decoder_test.go
new file mode 100644
index 00000000..84e4a7e0
--- /dev/null
+++ b/parquet/internal/encoding/streaming_decoder_test.go
@@ -0,0 +1,120 @@
+// 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 encoding_test
+
+import (
+ "bytes"
+ "encoding/binary"
+ "testing"
+ "testing/iotest"
+
+ "github.com/apache/arrow-go/v18/arrow/memory"
+ "github.com/apache/arrow-go/v18/parquet"
+ "github.com/apache/arrow-go/v18/parquet/internal/encoding"
+ "github.com/apache/arrow-go/v18/parquet/internal/encoding/streaming"
+ "github.com/apache/arrow-go/v18/parquet/schema"
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+func plainByteArrayBytes(vals []parquet.ByteArray) []byte {
+ var raw bytes.Buffer
+ var l [4]byte
+ for _, v := range vals {
+ binary.LittleEndian.PutUint32(l[:], uint32(len(v)))
+ raw.Write(l[:])
+ raw.Write(v)
+ }
+ return raw.Bytes()
+}
+
+func TestPlainByteArrayDecoderSetSource(t *testing.T) {
+ vals := []parquet.ByteArray{[]byte("hello"), []byte(""),
[]byte("world!!"), bytes.Repeat([]byte("x"), 5000)}
+
+ raw := plainByteArrayBytes(vals)
+ dec := encoding.NewDecoder(parquet.Types.ByteArray,
parquet.Encodings.Plain, nil, memory.DefaultAllocator)
+ sd, ok := dec.(streaming.Decoder)
+ require.True(t, ok, "PlainByteArrayDecoder must implement
streaming.Decoder")
+ sd.SetSource(len(vals),
streaming.NewStreamBuffer(memory.DefaultAllocator,
iotest.OneByteReader(bytes.NewReader(raw)), len(raw), nil))
+
+ out := make([]parquet.ByteArray, len(vals))
+ n, err := dec.(encoding.ByteArrayDecoder).Decode(out)
+ require.NoError(t, err)
+ require.Equal(t, len(vals), n)
+ for i := range vals {
+ assert.Equal(t, []byte(vals[i]), []byte(out[i]))
+ }
+}
+
+func TestPlainFixedLenByteArrayDecoderSetSource(t *testing.T) {
+ const w = 3
+ descr := schema.NewColumn(schema.NewFixedLenByteArrayNode("f",
parquet.Repetitions.Required, w, -1), 0, 0)
+ vals := []parquet.FixedLenByteArray{[]byte("abc"), []byte("def"),
bytes.Repeat([]byte("z"), w)}
+ raw := make([]byte, 0, len(vals)*w)
+ for _, v := range vals {
+ raw = append(raw, v...)
+ }
+
+ dec := encoding.NewDecoder(parquet.Types.FixedLenByteArray,
parquet.Encodings.Plain, descr, memory.DefaultAllocator)
+ sd, ok := dec.(streaming.Decoder)
+ require.True(t, ok, "PlainFixedLenByteArrayDecoder must implement
streaming.Decoder")
+ sd.SetSource(len(vals),
streaming.NewStreamBuffer(memory.DefaultAllocator,
iotest.OneByteReader(bytes.NewReader(raw)), len(raw), nil))
+
+ out := make([]parquet.FixedLenByteArray, len(vals))
+ n, err := dec.(encoding.FixedLenByteArrayDecoder).Decode(out)
+ require.NoError(t, err)
+ require.Equal(t, len(vals), n)
+ for i := range vals {
+ assert.Equal(t, []byte(vals[i]), []byte(out[i]))
+ }
+}
+
+func TestFixedWidthDecoderNotStreaming(t *testing.T) {
+ dec := encoding.NewDecoder(parquet.Types.Int64,
parquet.Encodings.Plain, nil, memory.DefaultAllocator)
+ _, ok := dec.(streaming.Decoder)
+ assert.False(t, ok)
+}
+
+func TestStreamingByteArrayDecodeErrors(t *testing.T) {
+ decode := func(raw []byte) error {
+ dec := encoding.NewDecoder(parquet.Types.ByteArray,
parquet.Encodings.Plain, nil, memory.DefaultAllocator)
+ dec.(streaming.Decoder).SetSource(1,
streaming.NewStreamBuffer(memory.DefaultAllocator, bytes.NewReader(raw),
len(raw), nil))
+ _, err :=
dec.(encoding.ByteArrayDecoder).Decode(make([]parquet.ByteArray, 1))
+ return err
+ }
+
+ t.Run("truncated value", func(t *testing.T) {
+ var prefix [4]byte
+ binary.LittleEndian.PutUint32(prefix[:], 100) // claims 100
bytes
+ require.Error(t, decode(append(prefix[:],
bytes.Repeat([]byte{0xAB}, 30)...)))
+ })
+ t.Run("negative length", func(t *testing.T) {
+ var prefix [4]byte
+ binary.LittleEndian.PutUint32(prefix[:], 0xFFFFFFFF) // -1 as
int32
+ require.Error(t, decode(prefix[:]))
+ })
+}
+
+func TestStreamingFixedLenByteArrayDecodeTruncated(t *testing.T) {
+ const w = 4
+ descr := schema.NewColumn(schema.NewFixedLenByteArrayNode("f",
parquet.Repetitions.Required, w, -1), 0, 0)
+ raw := []byte{0x01, 0x02} // 2 of 4 bytes
+ dec := encoding.NewDecoder(parquet.Types.FixedLenByteArray,
parquet.Encodings.Plain, descr, memory.DefaultAllocator)
+ dec.(streaming.Decoder).SetSource(1,
streaming.NewStreamBuffer(memory.DefaultAllocator, bytes.NewReader(raw),
len(raw), nil))
+ _, err :=
dec.(encoding.FixedLenByteArrayDecoder).Decode(make([]parquet.FixedLenByteArray,
1))
+ require.Error(t, err)
+}
diff --git a/parquet/reader_properties.go b/parquet/reader_properties.go
index 5b119dcf..36c7529e 100644
--- a/parquet/reader_properties.go
+++ b/parquet/reader_properties.go
@@ -44,6 +44,22 @@ type ReaderProperties struct {
// this to true can optimize memory usage for the reader. Additionally,
this can decrease
// the amount of data retrieved when only needs to access small
portions of the parquet file.
BufferedStreamEnabled bool
+ // PageStreamingEnabled, when true, decodes eligible data pages
incrementally instead
+ // of decoding the whole uncompressed page at once. A decode aliases
the batch's
+ // values directly in a reusable stream buffer (min(1 MiB, page size))
rather than
+ // materializing the page, so the read batch is clipped toward the
average value
+ // width to keep one decode's values near the buffer size. Peak memory
is then
+ // best-effort roughly that buffer plus the largest single value, the
caller's batch
+ // and the level buffers; skewed value sizes may overshoot the target.
Eligible pages
+ // are PLAIN-encoded V1/V2 data pages larger than 1 MiB (smaller pages
fit in a single
+ // buffer, so streaming only adds overhead), of a streaming-capable,
unencrypted codec
+ // (UNCOMPRESSED/GZIP/BROTLI/ZSTD) for a supported physical type; every
other page is
+ // read whole as before. Default false (no behavior change to decoded
values).
+ //
+ // Note: a streaming page's raw Page.Data() (via GetColumnPageReader)
returns just its
+ // level region; the decoded values read through
RowGroup.Column()/ReadBatch are the
+ // same as without streaming.
+ PageStreamingEnabled bool
}
type BufferedReader interface {
@@ -62,7 +78,7 @@ func NewReaderProperties(alloc memory.Allocator)
*ReaderProperties {
if alloc == nil {
alloc = memory.DefaultAllocator
}
- return &ReaderProperties{alloc, DefaultBufSize, nil, false}
+ return &ReaderProperties{alloc: alloc, BufferSize: DefaultBufSize}
}
// Allocator returns the allocator that the properties were initialized with
@@ -74,7 +90,7 @@ func (r *ReaderProperties) Allocator() memory.Allocator {
return r.alloc }
// into a buffer in memory and return a bytes.NewReader for that buffer.
func (r *ReaderProperties) GetStream(source io.ReaderAt, start, nbytes int64)
(BufferedReader, error) {
if r.BufferedStreamEnabled {
- return utils.NewBufferedReader(io.NewSectionReader(source,
start, nbytes), int(r.BufferSize)), nil
+ return utils.NewBufferedReader(io.NewSectionReader(source,
start, nbytes), int(min(r.BufferSize, nbytes))), nil
}
data := make([]byte, nbytes)