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 68e738f8 fix(parquet): copy ByteArray statistics min/max to prevent
use-after-free (#917)
68e738f8 is described below
commit 68e738f86506f61796e57d68551269552d76cdbf
Author: Matt Topol <[email protected]>
AuthorDate: Fri Jul 10 12:58:33 2026 -0400
fix(parquet): copy ByteArray statistics min/max to prevent use-after-free
(#917)
### Rationale for this change
`ByteArrayStatistics` (and the other `[]byte`-backed statistics types,
`FixedLenByteArrayStatistics` and `Float16Statistics`) stored their
min/max as
slices that **alias the caller's value buffer** — e.g. an Arrow array's
value
buffer — instead of copying the bytes. Every update path (`Update`,
`UpdateSpaced`, `UpdateFromArrow`, `Merge`) funnels through `SetMinMax`,
which
retained those slices directly.
When a column is written from a streaming `RecordReader` that releases
each
batch as it advances (the standard Arrow ownership contract, e.g.
`BindStream` +
`ExecuteUpdate` in the ADBC drivers), a previous batch's buffer is freed
before
the statistics are serialized. The retained min/max then reference freed
memory
and `bytes.Compare` in `less()` can segfault:
```
ByteArrayStatistics.less (statistics.go)
ByteArrayStatistics.SetMinMax (statistics_types.gen.go)
ByteArrayStatistics.UpdateSpaced (statistics_types.gen.go)
ByteArrayColumnChunkWriter.writeValuesSpaced
ByteArrayColumnChunkWriter.WriteBatchSpaced
pqarrow.writeDenseArrow
```
This mirrors the Apache Arrow C++ implementation
(`TypedStatisticsImpl<ByteArrayType>`), which stores min/max in
statistics-owned buffers and never aliases the input.
Reported at https://github.com/adbc-drivers/bigquery/issues/229.
### What changes are included in this PR?
Two separate commits:
1. **fix(parquet): copy ByteArray statistics min/max to prevent
use-after-free**
- `SetMinMax` now copies min/max into statistics-owned buffers for the
`[]byte`-backed physical types (`BYTE_ARRAY`, `FIXED_LEN_BYTE_ARRAY`,
and
the `FLOAT16` logical type), reusing the existing backing array to avoid
extra allocations. Scalar / `[N]byte` types are unchanged.
- Documents that `Min()`/`Max()` return a slice valid only until the
next
update for those types.
- The change is made in the generator template
(`statistics_types.gen.go.tmpl`); the generated file was regenerated.
2. **fix(parquet): correct FixedLenByteArray UpdateFromArrow max
computation**
- Pre-existing, independent bug: `FixedLenByteArray.UpdateFromArrow`
accumulated the running max with `maxval(min, v)` (the running *min*)
instead of `maxval(max, v)`. Kept as a separate commit for clarity.
### Are these changes tested?
Yes.
- `parquet/metadata`: new unit regressions assert stored min/max survive
the
source buffer being freed/overwritten for `BYTE_ARRAY`,
`FIXED_LEN_BYTE_ARRAY`
and `FLOAT16` (via `Update`/`UpdateSpaced`/`Merge`), plus a dedicated
test for
the `FixedLenByteArray.UpdateFromArrow` max-correctness fix.
- `parquet/pqarrow`: an end-to-end regression writes a string column
across two
buffered batches, releasing each batch before the next write. A
poison-on-free
allocator overwrites released buffers so the use-after-free is
deterministic;
the test asserts the round-tripped min/max are correct and the
`CheckedAllocator` reports no leaks. This test was confirmed to fail
without
the fix (max reads back as `\xff\xff\xff`) and to pass with it.
- `go test ./parquet/metadata/ ./parquet/file/ ./parquet/pqarrow/` all
pass;
`gofmt` and `go vet` are clean.
### Are there any user-facing changes?
No API changes. For `BYTE_ARRAY` / `FIXED_LEN_BYTE_ARRAY` / `FLOAT16`
statistics, `Min()`/`Max()` now return a slice backed by a reused,
statistics-owned buffer that is only valid until the next update that
replaces
the value (documented on the accessors). Statistics min/max are now
correct in
the streaming scenario that previously crashed, and the
`FixedLenByteArray`
Arrow-update max is now correct.
---
parquet/metadata/statistics_test.go | 182 ++++++++++++++++++++++++++
parquet/metadata/statistics_types.gen.go | 92 ++++++++++---
parquet/metadata/statistics_types.gen.go.tmpl | 48 ++++++-
parquet/pqarrow/encode_arrow_test.go | 67 ++++++++++
4 files changed, 369 insertions(+), 20 deletions(-)
diff --git a/parquet/metadata/statistics_test.go
b/parquet/metadata/statistics_test.go
index a0d107fa..6809eeef 100644
--- a/parquet/metadata/statistics_test.go
+++ b/parquet/metadata/statistics_test.go
@@ -21,6 +21,8 @@ import (
"reflect"
"testing"
+ "github.com/apache/arrow-go/v18/arrow"
+ "github.com/apache/arrow-go/v18/arrow/array"
"github.com/apache/arrow-go/v18/arrow/bitutil"
"github.com/apache/arrow-go/v18/arrow/float16"
"github.com/apache/arrow-go/v18/arrow/memory"
@@ -640,3 +642,183 @@ func TestNewStatisticsDistinctCountUnset(t *testing.T) {
})
}
}
+
+// TestByteArrayStatisticsDoesNotAliasInput is a regression test for
+// adbc-drivers/bigquery#229: ByteArrayStatistics stored the min/max as slices
+// aliasing the caller's value buffer (e.g. an Arrow array value buffer)
instead
+// of copying the bytes. When a streaming writer released a batch before the
+// statistics were serialized during Close, the lazily-accessed min/max pointed
+// at freed memory and bytes.Compare in less() segfaulted. SetMinMax must copy
+// the min/max into statistics-owned memory so they remain valid after the
+// source buffer is released.
+func TestByteArrayStatisticsDoesNotAliasInput(t *testing.T) {
+ descr := schema.NewColumn(schema.NewByteArrayNode("ba",
parquet.Repetitions.Required, -1), 0, 0)
+
+ t.Run("Update copies min/max", func(t *testing.T) {
+ stats := metadata.NewStatistics(descr,
memory.DefaultAllocator).(*metadata.ByteArrayStatistics)
+
+ buf := []byte("mmmmm")
+ stats.Update([]parquet.ByteArray{parquet.ByteArray(buf)}, 0)
+ require.True(t, stats.HasMinMax())
+ require.Equal(t, "mmmmm", string(stats.Min()))
+ require.Equal(t, "mmmmm", string(stats.Max()))
+
+ // Simulate the source buffer being freed/reused after the
batch is written.
+ copy(buf, "zzzzz")
+ assert.Equal(t, "mmmmm", string(stats.Min()), "min must not
alias the input buffer")
+ assert.Equal(t, "mmmmm", string(stats.Max()), "max must not
alias the input buffer")
+ })
+
+ t.Run("stored min/max survive freeing a prior batch", func(t
*testing.T) {
+ // Mirrors the crash flow directly: batch N establishes
min/max, its buffer
+ // is released, then batch N+1's SetMinMax compares new values
against the
+ // stored (previously dangling) min/max via less().
+ stats := metadata.NewStatistics(descr,
memory.DefaultAllocator).(*metadata.ByteArrayStatistics)
+
+ batch1 := []byte("aaazzz")
+ stats.UpdateSpaced([]parquet.ByteArray{batch1[0:3],
batch1[3:6]}, []byte{0x3}, 0, 0)
+ require.Equal(t, "aaa", string(stats.Min()))
+ require.Equal(t, "zzz", string(stats.Max()))
+
+ // Batch 1's buffer is released and its memory reused for other
data.
+ copy(batch1, "######")
+
+ batch2 := []byte("mmm")
+
stats.UpdateSpaced([]parquet.ByteArray{parquet.ByteArray(batch2)}, []byte{0x1},
0, 0)
+
+ assert.Equal(t, "aaa", string(stats.Min()), "min from a
released batch must survive")
+ assert.Equal(t, "zzz", string(stats.Max()), "max from a
released batch must survive")
+ })
+
+ t.Run("Merge copies min/max", func(t *testing.T) {
+ src := metadata.NewStatistics(descr,
memory.DefaultAllocator).(*metadata.ByteArrayStatistics)
+ src.Update([]parquet.ByteArray{parquet.ByteArray("kkkkk")}, 0)
+
+ dst := metadata.NewStatistics(descr,
memory.DefaultAllocator).(*metadata.ByteArrayStatistics)
+ dst.Merge(src)
+ require.Equal(t, "kkkkk", string(dst.Min()))
+ require.Equal(t, "kkkkk", string(dst.Max()))
+
+ // Updating src overwrites its statistics-owned min/max buffers
in place. If
+ // Merge had aliased src's buffers instead of copying, this
would corrupt dst.
+ src.Update([]parquet.ByteArray{parquet.ByteArray("aaaaa"),
parquet.ByteArray("zzzzz")}, 0)
+ require.Equal(t, "aaaaa", string(src.Min()))
+ require.Equal(t, "zzzzz", string(src.Max()))
+ assert.Equal(t, "kkkkk", string(dst.Min()), "merged min must be
independent of src")
+ assert.Equal(t, "kkkkk", string(dst.Max()), "merged max must be
independent of src")
+ })
+
+ t.Run("encoded min/max survive freeing the source", func(t *testing.T) {
+ stats := metadata.NewStatistics(descr,
memory.DefaultAllocator).(*metadata.ByteArrayStatistics)
+ buf := []byte("hello")
+ stats.Update([]parquet.ByteArray{parquet.ByteArray(buf)}, 0)
+
+ copy(buf, "world")
+ assert.Equal(t, []byte("hello"), stats.EncodeMin())
+ assert.Equal(t, []byte("hello"), stats.EncodeMax())
+ })
+}
+
+// TestFixedLenByteArrayStatisticsDoesNotAliasInput is the FIXED_LEN_BYTE_ARRAY
+// counterpart to TestByteArrayStatisticsDoesNotAliasInput
(adbc-drivers/bigquery#229).
+func TestFixedLenByteArrayStatisticsDoesNotAliasInput(t *testing.T) {
+ n, err := schema.NewPrimitiveNode("flba", parquet.Repetitions.Required,
parquet.Types.FixedLenByteArray, -1, 3)
+ require.NoError(t, err)
+ descr := schema.NewColumn(n, 0, 0)
+
+ stats := metadata.NewStatistics(descr,
memory.DefaultAllocator).(*metadata.FixedLenByteArrayStatistics)
+
+ buf := []byte("mmm")
+
stats.Update([]parquet.FixedLenByteArray{parquet.FixedLenByteArray(buf)}, 0)
+ require.True(t, stats.HasMinMax())
+ require.Equal(t, "mmm", string(stats.Min()))
+ require.Equal(t, "mmm", string(stats.Max()))
+
+ // Simulate the source buffer being freed/reused after the batch is
written.
+ copy(buf, "zzz")
+ assert.Equal(t, "mmm", string(stats.Min()), "min must not alias the
input buffer")
+ assert.Equal(t, "mmm", string(stats.Max()), "max must not alias the
input buffer")
+}
+
+// TestFloat16StatisticsDoesNotAliasInput covers the FLOAT16 logical type,
which
+// is backed by parquet.FixedLenByteArray and shares the copy-on-store fix
+// (adbc-drivers/bigquery#229).
+func TestFloat16StatisticsDoesNotAliasInput(t *testing.T) {
+ descr := schema.NewColumn(newFloat16Node("f16",
parquet.Repetitions.Required, -1), 0, 0)
+ stats := metadata.NewStatistics(descr,
memory.DefaultAllocator).(*metadata.Float16Statistics)
+
+ want := float16.New(1.5).ToLEBytes()
+ mutable := float16.New(1.5).ToLEBytes()
+
stats.Update([]parquet.FixedLenByteArray{parquet.FixedLenByteArray(mutable)}, 0)
+ require.True(t, stats.HasMinMax())
+ require.Equal(t, []byte(want), []byte(stats.Min()))
+
+ // Overwrite the source buffer to model it being freed/reused after the
batch.
+ copy(mutable, float16.New(9.5).ToLEBytes())
+ assert.Equal(t, []byte(want), []byte(stats.Min()), "min must not alias
the input buffer")
+ assert.Equal(t, []byte(want), []byte(stats.Max()), "max must not alias
the input buffer")
+}
+
+// TestFixedLenByteArrayStatisticsUpdateFromArrowMax is a regression test for a
+// separate pre-existing bug in FixedLenByteArray.UpdateFromArrow: it computed
+// the running max against the running min instead of the running max,
producing
+// an incorrect maximum for FIXED_LEN_BYTE_ARRAY columns updated from Arrow.
+func TestFixedLenByteArrayStatisticsUpdateFromArrowMax(t *testing.T) {
+ mem := memory.DefaultAllocator
+ n, err := schema.NewPrimitiveNode("flba", parquet.Repetitions.Required,
parquet.Types.FixedLenByteArray, -1, 3)
+ require.NoError(t, err)
+ descr := schema.NewColumn(n, 0, 0)
+ stats := metadata.NewStatistics(descr,
mem).(*metadata.FixedLenByteArrayStatistics)
+
+ bldr := array.NewFixedSizeBinaryBuilder(mem,
&arrow.FixedSizeBinaryType{ByteWidth: 3})
+ defer bldr.Release()
+ for _, v := range []string{"bbb", "aaa", "ccc", "abc"} {
+ bldr.Append([]byte(v))
+ }
+ arr := bldr.NewArray()
+ defer arr.Release()
+
+ require.NoError(t, stats.UpdateFromArrow(arr, true))
+ require.True(t, stats.HasMinMax())
+ assert.Equal(t, "aaa", string(stats.Min()))
+ assert.Equal(t, "ccc", string(stats.Max()), "max must be computed
against the running max, not the running min")
+}
+
+type encodedStatProvider struct {
+ min, max []byte
+}
+
+func (e *encodedStatProvider) GetMin() []byte { return e.min }
+func (e *encodedStatProvider) GetMax() []byte { return e.max }
+func (e *encodedStatProvider) GetNullCount() int64 { return 0 }
+func (e *encodedStatProvider) GetDistinctCount() int64 { return 0 }
+func (e *encodedStatProvider) IsSetMax() bool { return e.max != nil }
+func (e *encodedStatProvider) IsSetMin() bool { return e.min != nil }
+func (e *encodedStatProvider) IsSetNullCount() bool { return false }
+func (e *encodedStatProvider) IsSetDistinctCount() bool { return false }
+
+// TestByteArrayStatisticsFromEncodedOwnsMinMax is a regression test for a
review
+// finding on the use-after-free fix: statistics built from encoded metadata
via
+// New*StatisticsFromEncoded stored min/max slices aliasing the caller's
metadata
+// buffer. Because SetMinMax now reuses those buffers on update, a later update
+// could write through and corrupt the caller's metadata, so the decoded
min/max
+// must be copied into statistics-owned buffers.
+func TestByteArrayStatisticsFromEncodedOwnsMinMax(t *testing.T) {
+ descr := schema.NewColumn(schema.NewByteArrayNode("ba",
parquet.Repetitions.Required, -1), 0, 0)
+
+ encMin := []byte("bbb")
+ encMax := []byte("yyy")
+ stats := metadata.NewStatisticsFromEncoded(descr,
memory.DefaultAllocator, 4,
+ &encodedStatProvider{min: encMin, max:
encMax}).(*metadata.ByteArrayStatistics)
+ require.True(t, stats.HasMinMax())
+ require.Equal(t, "bbb", string(stats.Min()))
+ require.Equal(t, "yyy", string(stats.Max()))
+
+ // Updating with new extremes must not write through into the encoded
source
+ // buffers, which would happen if the stored min/max still aliased them.
+ stats.Update([]parquet.ByteArray{parquet.ByteArray("aaa"),
parquet.ByteArray("zzz")}, 0)
+ assert.Equal(t, "bbb", string(encMin), "encoded min source buffer must
not be overwritten")
+ assert.Equal(t, "yyy", string(encMax), "encoded max source buffer must
not be overwritten")
+ assert.Equal(t, "aaa", string(stats.Min()))
+ assert.Equal(t, "zzz", string(stats.Max()))
+}
diff --git a/parquet/metadata/statistics_types.gen.go
b/parquet/metadata/statistics_types.gen.go
index a9a19ea1..e450fb7c 100644
--- a/parquet/metadata/statistics_types.gen.go
+++ b/parquet/metadata/statistics_types.gen.go
@@ -1899,11 +1899,13 @@ func NewByteArrayStatisticsFromEncoded(descr
*schema.Column, mem memory.Allocato
encodedMin := encoded.GetMin()
if encodedMin != nil && len(encodedMin) > 0 {
- ret.min = ret.plainDecode(encodedMin)
+ // Copy into a statistics-owned buffer so the stored min does
not alias the
+ // encoded metadata buffer; SetMinMax reuses this buffer on
later updates.
+ ret.min = append(ret.min[:0], ret.plainDecode(encodedMin)...)
}
encodedMax := encoded.GetMax()
if encodedMax != nil && len(encodedMax) > 0 {
- ret.max = ret.plainDecode(encodedMax)
+ ret.max = append(ret.max[:0], ret.plainDecode(encodedMax)...)
}
ret.hasMinMax = encoded.IsSetMax() || encoded.IsSetMin()
return ret
@@ -2007,7 +2009,18 @@ func (s *ByteArrayStatistics) getMinMaxSpaced(values
[]parquet.ByteArray, validB
return
}
+// Min returns the current minimum value.
+//
+// The returned slice references a statistics-owned buffer that is reused on
+// subsequent updates, so it is only valid until the next update that replaces
+// the minimum. Copy it if you need to retain it.
func (s *ByteArrayStatistics) Min() parquet.ByteArray { return s.min }
+
+// Max returns the current maximum value.
+//
+// The returned slice references a statistics-owned buffer that is reused on
+// subsequent updates, so it is only valid until the next update that replaces
+// the maximum. Copy it if you need to retain it.
func (s *ByteArrayStatistics) Max() parquet.ByteArray { return s.max }
// Merge merges the stats from other into this stat object, updating
@@ -2093,6 +2106,11 @@ func (s *ByteArrayStatistics) UpdateFromArrow(values
arrow.Array, updateCounts b
// SetMinMax updates the min and max values only if they are not currently set
// or if argMin is less than the current min / argMax is greater than the
current max
+//
+// The min and max are copied into statistics-owned buffers rather than
retaining
+// the provided slices. Those slices typically alias the source column data
(e.g.
+// an Arrow value buffer) which may be released before the statistics are
+// serialized during Close, so aliasing them would leave dangling references.
func (s *ByteArrayStatistics) SetMinMax(argMin, argMax parquet.ByteArray) {
maybeMinMax := s.cleanStat([2]parquet.ByteArray{argMin, argMax})
if maybeMinMax == nil {
@@ -2104,14 +2122,14 @@ func (s *ByteArrayStatistics) SetMinMax(argMin, argMax
parquet.ByteArray) {
if !s.hasMinMax {
s.hasMinMax = true
- s.min = min
- s.max = max
+ s.min = append(s.min[:0], min...)
+ s.max = append(s.max[:0], max...)
} else {
if !s.less(s.min, min) {
- s.min = min
+ s.min = append(s.min[:0], min...)
}
if s.less(s.max, max) {
- s.max = max
+ s.max = append(s.max[:0], max...)
}
}
}
@@ -2203,11 +2221,13 @@ func NewFixedLenByteArrayStatisticsFromEncoded(descr
*schema.Column, mem memory.
encodedMin := encoded.GetMin()
if encodedMin != nil && len(encodedMin) > 0 {
- ret.min = ret.plainDecode(encodedMin)
+ // Copy into a statistics-owned buffer so the stored min does
not alias the
+ // encoded metadata buffer; SetMinMax reuses this buffer on
later updates.
+ ret.min = append(ret.min[:0], ret.plainDecode(encodedMin)...)
}
encodedMax := encoded.GetMax()
if encodedMax != nil && len(encodedMax) > 0 {
- ret.max = ret.plainDecode(encodedMax)
+ ret.max = append(ret.max[:0], ret.plainDecode(encodedMax)...)
}
ret.hasMinMax = encoded.IsSetMax() || encoded.IsSetMin()
return ret
@@ -2323,7 +2343,18 @@ func (s *FixedLenByteArrayStatistics)
getMinMaxSpaced(values []parquet.FixedLenB
return
}
+// Min returns the current minimum value.
+//
+// The returned slice references a statistics-owned buffer that is reused on
+// subsequent updates, so it is only valid until the next update that replaces
+// the minimum. Copy it if you need to retain it.
func (s *FixedLenByteArrayStatistics) Min() parquet.FixedLenByteArray { return
s.min }
+
+// Max returns the current maximum value.
+//
+// The returned slice references a statistics-owned buffer that is reused on
+// subsequent updates, so it is only valid until the next update that replaces
+// the maximum. Copy it if you need to retain it.
func (s *FixedLenByteArrayStatistics) Max() parquet.FixedLenByteArray { return
s.max }
// Merge merges the stats from other into this stat object, updating
@@ -2394,7 +2425,7 @@ func (s *FixedLenByteArrayStatistics)
UpdateFromArrow(values arrow.Array, update
for i := 0; i < values.Len(); i++ {
v := data[i*width : (i+1)*width]
min = s.minval(min, v)
- max = s.maxval(min, v)
+ max = s.maxval(max, v)
}
s.SetMinMax(min, max)
@@ -2403,6 +2434,11 @@ func (s *FixedLenByteArrayStatistics)
UpdateFromArrow(values arrow.Array, update
// SetMinMax updates the min and max values only if they are not currently set
// or if argMin is less than the current min / argMax is greater than the
current max
+//
+// The min and max are copied into statistics-owned buffers rather than
retaining
+// the provided slices. Those slices typically alias the source column data
(e.g.
+// an Arrow value buffer) which may be released before the statistics are
+// serialized during Close, so aliasing them would leave dangling references.
func (s *FixedLenByteArrayStatistics) SetMinMax(argMin, argMax
parquet.FixedLenByteArray) {
maybeMinMax := s.cleanStat([2]parquet.FixedLenByteArray{argMin, argMax})
if maybeMinMax == nil {
@@ -2414,14 +2450,14 @@ func (s *FixedLenByteArrayStatistics) SetMinMax(argMin,
argMax parquet.FixedLenB
if !s.hasMinMax {
s.hasMinMax = true
- s.min = min
- s.max = max
+ s.min = append(s.min[:0], min...)
+ s.max = append(s.max[:0], max...)
} else {
if !s.less(s.min, min) {
- s.min = min
+ s.min = append(s.min[:0], min...)
}
if s.less(s.max, max) {
- s.max = max
+ s.max = append(s.max[:0], max...)
}
}
}
@@ -2517,11 +2553,13 @@ func NewFloat16StatisticsFromEncoded(descr
*schema.Column, mem memory.Allocator,
encodedMin := encoded.GetMin()
if encodedMin != nil && len(encodedMin) > 0 {
- ret.min = ret.plainDecode(encodedMin)
+ // Copy into a statistics-owned buffer so the stored min does
not alias the
+ // encoded metadata buffer; SetMinMax reuses this buffer on
later updates.
+ ret.min = append(ret.min[:0], ret.plainDecode(encodedMin)...)
}
encodedMax := encoded.GetMax()
if encodedMax != nil && len(encodedMax) > 0 {
- ret.max = ret.plainDecode(encodedMax)
+ ret.max = append(ret.max[:0], ret.plainDecode(encodedMax)...)
}
ret.hasMinMax = encoded.IsSetMax() || encoded.IsSetMin()
return ret
@@ -2644,7 +2682,18 @@ func (s *Float16Statistics) getMinMaxSpaced(values
[]parquet.FixedLenByteArray,
return
}
+// Min returns the current minimum value.
+//
+// The returned slice references a statistics-owned buffer that is reused on
+// subsequent updates, so it is only valid until the next update that replaces
+// the minimum. Copy it if you need to retain it.
func (s *Float16Statistics) Min() parquet.FixedLenByteArray { return s.min }
+
+// Max returns the current maximum value.
+//
+// The returned slice references a statistics-owned buffer that is reused on
+// subsequent updates, so it is only valid until the next update that replaces
+// the maximum. Copy it if you need to retain it.
func (s *Float16Statistics) Max() parquet.FixedLenByteArray { return s.max }
// Merge merges the stats from other into this stat object, updating
@@ -2704,6 +2753,11 @@ func (s *Float16Statistics) UpdateFromArrow(values
arrow.Array, updateCounts boo
// SetMinMax updates the min and max values only if they are not currently set
// or if argMin is less than the current min / argMax is greater than the
current max
+//
+// The min and max are copied into statistics-owned buffers rather than
retaining
+// the provided slices. Those slices typically alias the source column data
(e.g.
+// an Arrow value buffer) which may be released before the statistics are
+// serialized during Close, so aliasing them would leave dangling references.
func (s *Float16Statistics) SetMinMax(argMin, argMax
parquet.FixedLenByteArray) {
maybeMinMax := s.cleanStat([2]parquet.FixedLenByteArray{argMin, argMax})
if maybeMinMax == nil {
@@ -2715,14 +2769,14 @@ func (s *Float16Statistics) SetMinMax(argMin, argMax
parquet.FixedLenByteArray)
if !s.hasMinMax {
s.hasMinMax = true
- s.min = min
- s.max = max
+ s.min = append(s.min[:0], min...)
+ s.max = append(s.max[:0], max...)
} else {
if !s.less(s.min, min) {
- s.min = min
+ s.min = append(s.min[:0], min...)
}
if s.less(s.max, max) {
- s.max = max
+ s.max = append(s.max[:0], max...)
}
}
}
diff --git a/parquet/metadata/statistics_types.gen.go.tmpl
b/parquet/metadata/statistics_types.gen.go.tmpl
index 76657943..0f0bc377 100644
--- a/parquet/metadata/statistics_types.gen.go.tmpl
+++ b/parquet/metadata/statistics_types.gen.go.tmpl
@@ -87,11 +87,21 @@ func New{{.Name}}StatisticsFromEncoded(descr
*schema.Column, mem memory.Allocato
encodedMin := encoded.GetMin()
if encodedMin != nil && len(encodedMin) > 0 {
+{{- if or (eq .name "parquet.ByteArray") (eq .name
"parquet.FixedLenByteArray")}}
+ // Copy into a statistics-owned buffer so the stored min does not alias the
+ // encoded metadata buffer; SetMinMax reuses this buffer on later updates.
+ ret.min = append(ret.min[:0], ret.plainDecode(encodedMin)...)
+{{- else}}
ret.min = ret.plainDecode(encodedMin)
+{{- end}}
}
encodedMax := encoded.GetMax()
if encodedMax != nil && len(encodedMax) > 0 {
+{{- if or (eq .name "parquet.ByteArray") (eq .name
"parquet.FixedLenByteArray")}}
+ ret.max = append(ret.max[:0], ret.plainDecode(encodedMax)...)
+{{- else}}
ret.max = ret.plainDecode(encodedMax)
+{{- end}}
}
ret.hasMinMax = encoded.IsSetMax() || encoded.IsSetMin()
return ret
@@ -299,8 +309,24 @@ func (s *{{.Name}}Statistics) getMinMaxSpaced(values
[]{{.name}}, validBits []by
return
}
+{{if or (eq .name "parquet.ByteArray") (eq .name "parquet.FixedLenByteArray")}}
+// Min returns the current minimum value.
+//
+// The returned slice references a statistics-owned buffer that is reused on
+// subsequent updates, so it is only valid until the next update that replaces
+// the minimum. Copy it if you need to retain it.
+func (s *{{.Name}}Statistics) Min() {{.name}} { return s.min }
+
+// Max returns the current maximum value.
+//
+// The returned slice references a statistics-owned buffer that is reused on
+// subsequent updates, so it is only valid until the next update that replaces
+// the maximum. Copy it if you need to retain it.
+func (s *{{.Name}}Statistics) Max() {{.name}} { return s.max }
+{{else}}
func (s *{{.Name}}Statistics) Min() {{.name}} { return s.min }
func (s *{{.Name}}Statistics) Max() {{.name}} { return s.max }
+{{end}}
// Merge merges the stats from other into this stat object, updating
// the null count, distinct count, number of values and the min/max if
@@ -370,7 +396,7 @@ func (s *{{.Name}}Statistics) UpdateFromArrow(values
arrow.Array, updateCounts b
for i := 0; i < values.Len(); i++ {
v := data[i * width : (i+1) * width]
min = s.minval(min, v)
- max = s.maxval(min, v)
+ max = s.maxval(max, v)
}
s.SetMinMax(min, max)
@@ -501,6 +527,13 @@ func (s *{{.Name}}Statistics) getMinMaxFromBitmap(bitmap
[]byte, bitmapOffset in
// SetMinMax updates the min and max values only if they are not currently set
// or if argMin is less than the current min / argMax is greater than the
current max
+{{- if or (eq .name "parquet.ByteArray") (eq .name
"parquet.FixedLenByteArray")}}
+//
+// The min and max are copied into statistics-owned buffers rather than
retaining
+// the provided slices. Those slices typically alias the source column data
(e.g.
+// an Arrow value buffer) which may be released before the statistics are
+// serialized during Close, so aliasing them would leave dangling references.
+{{- end}}
func (s *{{.Name}}Statistics) SetMinMax(argMin, argMax {{.name}}) {
maybeMinMax := s.cleanStat([2]{{.name}}{argMin, argMax})
if maybeMinMax == nil {
@@ -512,14 +545,27 @@ func (s *{{.Name}}Statistics) SetMinMax(argMin, argMax
{{.name}}) {
if !s.hasMinMax {
s.hasMinMax = true
+{{- if or (eq .name "parquet.ByteArray") (eq .name
"parquet.FixedLenByteArray")}}
+ s.min = append(s.min[:0], min...)
+ s.max = append(s.max[:0], max...)
+{{- else}}
s.min = min
s.max = max
+{{- end}}
} else {
if !s.less(s.min, min) {
+{{- if or (eq .name "parquet.ByteArray") (eq .name
"parquet.FixedLenByteArray")}}
+ s.min = append(s.min[:0], min...)
+{{- else}}
s.min = min
+{{- end}}
}
if s.less(s.max, max) {
+{{- if or (eq .name "parquet.ByteArray") (eq .name
"parquet.FixedLenByteArray")}}
+ s.max = append(s.max[:0], max...)
+{{- else}}
s.max = max
+{{- end}}
}
}
}
diff --git a/parquet/pqarrow/encode_arrow_test.go
b/parquet/pqarrow/encode_arrow_test.go
index 95a5b3e5..197f6dcb 100644
--- a/parquet/pqarrow/encode_arrow_test.go
+++ b/parquet/pqarrow/encode_arrow_test.go
@@ -2780,3 +2780,70 @@ func TestReadWriteShreddedVariant(t *testing.T) {
assert.Truef(t, array.Equal(arr, tbl.Column(0).Data().Chunk(0)),
"expected: %s\ngot: %s", arr, tbl.Column(0).Data().Chunk(0))
}
+
+// poisonOnFreeAllocator overwrites every buffer with 0xFF as it is freed so
that
+// any lingering alias into released memory becomes observable. This gives a
+// deterministic reproduction of the adbc-drivers/bigquery#229 use-after-free
+// instead of depending on the Go runtime to reuse the freed pages.
+type poisonOnFreeAllocator struct {
+ memory.Allocator
+}
+
+func (p poisonOnFreeAllocator) Free(b []byte) {
+ for i := range b {
+ b[i] = 0xff
+ }
+ p.Allocator.Free(b)
+}
+
+// TestByteArrayStatisticsStreamingReleaseBetweenBatches reproduces
+// adbc-drivers/bigquery#229 end to end. With statistics enabled (the default)
+// and dictionary encoding disabled (so the dense Arrow write path runs), a
+// streaming writer releases each batch before writing the next. The ByteArray
+// column statistics must copy their min/max, so the round-tripped values stay
+// correct even though batch 1's buffer was poisoned when it was released
before
+// batch 2's write compared against the stored min/max.
+func TestByteArrayStatisticsStreamingReleaseBetweenBatches(t *testing.T) {
+ checked := memory.NewCheckedAllocator(memory.DefaultAllocator)
+ defer checked.AssertSize(t, 0)
+ mem := poisonOnFreeAllocator{checked}
+
+ sc := arrow.NewSchema([]arrow.Field{{Name: "s", Type:
arrow.BinaryTypes.String}}, nil)
+
+ newBatch := func(vals ...string) arrow.RecordBatch {
+ bldr := array.NewRecordBuilder(mem, sc)
+ defer bldr.Release()
+ bldr.Field(0).(*array.StringBuilder).AppendValues(vals, nil)
+ return bldr.NewRecordBatch()
+ }
+
+ var buf bytes.Buffer
+ w, err := pqarrow.NewFileWriter(sc, &buf,
+
parquet.NewWriterProperties(parquet.WithDictionaryDefault(false)),
+ pqarrow.NewArrowWriterProperties(pqarrow.WithAllocator(mem)))
+ require.NoError(t, err)
+
+ batch1 := newBatch("mmm", "zzz")
+ require.NoError(t, w.WriteBuffered(batch1))
+ batch1.Release()
+
+ batch2 := newBatch("aaa", "nnn")
+ require.NoError(t, w.WriteBuffered(batch2))
+ batch2.Release()
+
+ require.NoError(t, w.Close())
+
+ rdr, err := file.NewParquetReader(bytes.NewReader(buf.Bytes()))
+ require.NoError(t, err)
+ defer rdr.Close()
+
+ md := rdr.MetaData()
+ require.Equal(t, 1, len(md.RowGroups))
+ col, err := md.RowGroup(0).ColumnChunk(0)
+ require.NoError(t, err)
+ stats, err := col.Statistics()
+ require.NoError(t, err)
+ require.True(t, stats.HasMinMax())
+ assert.Equal(t, "aaa", string(stats.EncodeMin()), "min corrupted by
released batch buffer")
+ assert.Equal(t, "zzz", string(stats.EncodeMax()), "max corrupted by
released batch buffer")
+}