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 153ec5bf fix(parquet/pqarrow): release partial readers when building a
field reader fails (#1435)
153ec5bf is described below
commit 153ec5bf362c2c6322fbf1f3d5f87ecb1ffe4746
Author: Steve O'Brien <[email protected]>
AuthorDate: Mon Nov 2 13:40:39 2026 -0500
fix(parquet/pqarrow): release partial readers when building a field reader
fails (#1435)
### Rationale for this change
Two error paths while building a field reader leave readers unreleased:
- In `getReader`, when one child of a struct fails to build, the readers
already built for its siblings are dropped without `Release`. With
`errgroup.WithContext` and `SetLimit(1)`, the children after the failing
one are still built too, since `getReader` does not check the canceled
context. Their page readers' buffers leak: 32 KiB for a 3-child struct
of `int32`s in the new test.
- `newLeafReader` returns a live `*ColumnReader` together with its
`nextRowGroup` error. Callers check the error and drop the reader, so
nobody releases it.
`GetFieldReaders` already releases its partial readers on error; this
applies the same cleanup inside `getReader`.
### What changes are included in this PR?
- `getReader` releases the struct's already-built child readers before
returning a child's error.
- `newLeafReader` releases its reader and returns `nil` when
`nextRowGroup` fails.
- Tests:
- `TestGetFieldReaderReleasesStructChildrenOnError` fails a struct's
second child and checks that no allocations remain, with `Parallel`
false and true. On `main` it reports two 16 KiB leaks from
`serializedPageReader.init`.
- `TestGetFieldReaderReturnsNilReaderOnError` checks that a failing leaf
returns a nil reader. On `main` it gets a `*leafReader`.
Both reuse `failingReaderAt` from
`TestGetFieldReadersReleasesPartialReadersOnError`.
This overlaps #1434, which adds a serial loop for struct children in the
same function. Whichever lands second should release the built children
in that loop too.
### Are these changes tested?
Yes:
- `PARQUET_TEST_DATA=<parquet-testing-data> go test ./parquet/...`
- `go test -race ./parquet/pqarrow -run
'^(TestGetFieldReader|TestGetFieldReaders|TestRecordReader|TestPartialStructColumnRead|TestStructReaderSerial)'
-count=1`
- `go vet ./parquet/pqarrow`, `git diff --check`
### Are there any user-facing changes?
`GetFieldReader` now returns a nil reader with its error when a leaf
column fails to open, where it used to return an unreleased reader.
Struct reads that fail no longer leak memory from a non-Go allocator.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-authored-by: Claude Opus 5.5 <[email protected]>
---
parquet/pqarrow/column_readers.go | 7 ++-
parquet/pqarrow/file_reader.go | 5 ++
parquet/pqarrow/file_reader_test.go | 96 +++++++++++++++++++++++++++++++++++++
3 files changed, 106 insertions(+), 2 deletions(-)
diff --git a/parquet/pqarrow/column_readers.go
b/parquet/pqarrow/column_readers.go
index b90072ea..237df7d8 100644
--- a/parquet/pqarrow/column_readers.go
+++ b/parquet/pqarrow/column_readers.go
@@ -67,8 +67,11 @@ func newLeafReader(rctx *readerCtx, field *arrow.Field,
input *columnIterator, l
}
ret.refCount.Add(1)
- err := ret.nextRowGroup(0)
- return &ColumnReader{ret}, err
+ if err := ret.nextRowGroup(0); err != nil {
+ ret.Release()
+ return nil, err
+ }
+ return &ColumnReader{ret}, nil
}
func (lr *leafReader) Retain() {
diff --git a/parquet/pqarrow/file_reader.go b/parquet/pqarrow/file_reader.go
index 36382a6a..d0b5f9b3 100644
--- a/parquet/pqarrow/file_reader.go
+++ b/parquet/pqarrow/file_reader.go
@@ -678,6 +678,11 @@ func (fr *FileReader) getReader(ctx context.Context, field
*SchemaField, arrowFi
})
}
if err = g.Wait(); err != nil {
+ for _, rdr := range childReaders {
+ if rdr != nil {
+ rdr.Release()
+ }
+ }
return nil, err
}
diff --git a/parquet/pqarrow/file_reader_test.go
b/parquet/pqarrow/file_reader_test.go
index 737dff2a..e9f1ecbb 100644
--- a/parquet/pqarrow/file_reader_test.go
+++ b/parquet/pqarrow/file_reader_test.go
@@ -251,6 +251,102 @@ func TestGetFieldReadersReleasesPartialReadersOnError(t
*testing.T) {
require.Zero(t, mem.CurrentAlloc())
}
+func TestGetFieldReaderReturnsNilReaderOnError(t *testing.T) {
+ schema := arrow.NewSchema([]arrow.Field{
+ {Name: "first", Type: arrow.PrimitiveTypes.Int32},
+ {Name: "second", Type: arrow.PrimitiveTypes.Int32},
+ }, nil)
+ record, _, err := array.RecordFromJSON(memory.DefaultAllocator, schema,
+ strings.NewReader(`[{"first": 1, "second": 2}]`))
+ require.NoError(t, err)
+ defer record.Release()
+
+ var buf bytes.Buffer
+ writer, err := pqarrow.NewFileWriter(schema, &buf, nil,
pqarrow.DefaultWriterProps())
+ require.NoError(t, err)
+ require.NoError(t, writer.Write(record))
+ require.NoError(t, writer.Close())
+
+ readErr := errors.New("read second column")
+ source := &failingReaderAt{
+ Reader: bytes.NewReader(buf.Bytes()),
+ failOffset: -1,
+ err: readErr,
+ }
+ mem := memory.NewCheckedAllocator(memory.DefaultAllocator)
+ defer mem.AssertSize(t, 0)
+
+ parquetReader, err := file.NewParquetReader(source,
+ file.WithReadProps(parquet.NewReaderProperties(mem)))
+ require.NoError(t, err)
+ defer parquetReader.Close()
+
+ column, err := parquetReader.MetaData().RowGroup(0).ColumnChunk(1)
+ require.NoError(t, err)
+ source.failOffset = column.DataPageOffset()
+ if column.HasDictionaryPage() && column.DictionaryPageOffset() > 0 {
+ source.failOffset = column.DictionaryPageOffset()
+ }
+
+ reader, err := pqarrow.NewFileReader(parquetReader,
pqarrow.ArrowReadProperties{}, mem)
+ require.NoError(t, err)
+ fieldReader, err := reader.GetFieldReader(context.Background(), 1,
map[int]bool{1: true}, []int{0})
+ require.ErrorIs(t, err, readErr)
+ require.Nil(t, fieldReader, "a reader returned with an error is never
released by callers")
+}
+
+func TestGetFieldReaderReleasesStructChildrenOnError(t *testing.T) {
+ structType := arrow.StructOf(
+ arrow.Field{Name: "first", Type: arrow.PrimitiveTypes.Int32},
+ arrow.Field{Name: "second", Type: arrow.PrimitiveTypes.Int32},
+ arrow.Field{Name: "third", Type: arrow.PrimitiveTypes.Int32},
+ )
+ schema := arrow.NewSchema([]arrow.Field{{Name: "nested", Type:
structType}}, nil)
+ record, _, err := array.RecordFromJSON(memory.DefaultAllocator, schema,
+ strings.NewReader(`[{"nested": {"first": 1, "second": 2,
"third": 3}}]`))
+ require.NoError(t, err)
+ defer record.Release()
+
+ var buf bytes.Buffer
+ writer, err := pqarrow.NewFileWriter(schema, &buf, nil,
pqarrow.DefaultWriterProps())
+ require.NoError(t, err)
+ require.NoError(t, writer.Write(record))
+ require.NoError(t, writer.Close())
+
+ for _, parallel := range []bool{false, true} {
+ t.Run(fmt.Sprintf("parallel=%t", parallel), func(t *testing.T) {
+ readErr := errors.New("read second child")
+ source := &failingReaderAt{
+ Reader: bytes.NewReader(buf.Bytes()),
+ failOffset: -1,
+ err: readErr,
+ }
+ mem :=
memory.NewCheckedAllocator(memory.DefaultAllocator)
+ defer mem.AssertSize(t, 0)
+
+ parquetReader, err := file.NewParquetReader(source,
+
file.WithReadProps(parquet.NewReaderProperties(mem)))
+ require.NoError(t, err)
+ defer parquetReader.Close()
+
+ column, err :=
parquetReader.MetaData().RowGroup(0).ColumnChunk(1)
+ require.NoError(t, err)
+ source.failOffset = column.DataPageOffset()
+ if column.HasDictionaryPage() &&
column.DictionaryPageOffset() > 0 {
+ source.failOffset =
column.DictionaryPageOffset()
+ }
+
+ reader, err := pqarrow.NewFileReader(parquetReader,
pqarrow.ArrowReadProperties{Parallel: parallel}, mem)
+ require.NoError(t, err)
+ fieldReader, err :=
reader.GetFieldReader(context.Background(), 0,
+ map[int]bool{0: true, 1: true, 2: true},
[]int{0})
+ require.ErrorIs(t, err, readErr)
+ require.Nil(t, fieldReader)
+ require.Zero(t, mem.CurrentAlloc())
+ })
+ }
+}
+
func TestRecordReaderParallel(t *testing.T) {
mem := memory.NewCheckedAllocator(memory.DefaultAllocator)
defer mem.AssertSize(t, 0)