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 5bc241e8 fix(parquet): build bloom filters from dictionary entries
(#1164)
5bc241e8 is described below
commit 5bc241e801ed76a1c5226bf479c84b794d2bcfd5
Author: Minh Vu <[email protected]>
AuthorDate: Wed Aug 26 21:30:01 2026 +0200
fix(parquet): build bloom filters from dictionary entries (#1164)
## Summary
- track dictionary entries referenced by encoded values
- populate Bloom filters from those entries once per column chunk
- cover direct Arrow dictionary arrays, multiple data pages, null
indices, and dictionary fallback
- keep the existing per-value path after fallback to plain encoding
## Why
Bloom filter updates currently happen in the ordinary value-writing
paths. Direct dictionary-array writes bypass those paths and write
indices directly, which leaves their Bloom filters empty. This can
produce false negatives when readers use the filter.
Ordinary dictionary encoding also hashes every logical value even though
inserting the same hash repeatedly does not change the filter. Reusing
the dictionary reduces hashing from the row count to the number of
referenced dictionary entries while dictionary encoding remains active.
## Benchmark
Apple M1 Pro, 100,000 int32 values, median of 10 runs with a 500 ms
benchmark time:
| Cardinality | Before | After | Speedup | B/op | allocs/op |
|---:|---:|---:|---:|---:|---:|
| 1 | 3.568 ms | 1.745 ms | 2.04x as fast | -75.0% | -51.8% |
| 10 | 3.902 ms | 2.136 ms | 1.83x as fast | -71.7% | -50.8% |
| 100 | 3.956 ms | 2.118 ms | 1.87x as fast | -69.5% | -49.0% |
Speedups are calculated directly from the displayed median times.
Higher-cardinality cases that trigger dictionary fallback remain close
to the existing path.
## Tests
- full repository test suite
- race tests for parquet/file, parquet/internal/encoding, and
parquet/pqarrow
- vet for the changed packages
---
parquet/file/column_writer.go | 86 +++++++++-
parquet/file/column_writer_bloom_internal_test.go | 117 ++++++++++++++
parquet/file/column_writer_test.go | 47 ++++++
parquet/file/column_writer_types.gen.go | 112 ++++++++-----
parquet/file/column_writer_types.gen.go.tmpl | 14 +-
parquet/file/file_writer_test.go | 81 ++++++++++
parquet/file/writer_performance_test.go | 80 +++++++++
parquet/internal/encoding/encoder.go | 189 ++++++++++++++++------
parquet/internal/encoding/typed_encoder.go | 7 +-
parquet/internal/encoding/typed_encoder_test.go | 98 +++++++++++
parquet/internal/encoding/types.go | 15 +-
parquet/metadata/bloom_filter.go | 66 ++++++++
parquet/metadata/bloom_filter_test.go | 61 +++++++
parquet/pqarrow/encode_dict_compute.go | 2 +-
parquet/pqarrow/encode_dictionary_test.go | 145 +++++++++++++++++
15 files changed, 1021 insertions(+), 99 deletions(-)
diff --git a/parquet/file/column_writer.go b/parquet/file/column_writer.go
index b19d1ce9..5da1663b 100644
--- a/parquet/file/column_writer.go
+++ b/parquet/file/column_writer.go
@@ -115,9 +115,10 @@ type columnWriter struct {
repEncoder encoding.LevelEncoder
mem memory.Allocator
- pageStatistics metadata.TypedStatistics
- chunkStatistics metadata.TypedStatistics
- bloomFilter metadata.BloomFilterBuilder
+ pageStatistics metadata.TypedStatistics
+ chunkStatistics metadata.TypedStatistics
+ bloomFilter metadata.BloomFilterBuilder
+ dictBloomPopulated bool
// total number of values stored in the current data page. this is the
maximum
// of the number of encoded def levels or encoded values. for
@@ -519,6 +520,9 @@ func (w *columnWriter) WriteDictionaryPage() error {
buffer.Resize(dictEncoder.DictEncodedSize())
dictEncoder.WriteDict(buffer.Bytes())
defer buffer.Release()
+ if err := w.populateBloomFilterFromDictionary(dictEncoder,
buffer.Bytes()); err != nil {
+ return err
+ }
page := NewDictionaryPage(buffer, int32(dictEncoder.NumEntries()),
w.props.DictionaryPageEncoding())
written, err := w.pager.WriteDictionaryPage(page)
@@ -527,6 +531,82 @@ func (w *columnWriter) WriteDictionaryPage() error {
return err
}
+func (w *columnWriter) populateBloomFilterFromEncoder(dictEncoder
encoding.DictEncoder) error {
+ if w.bloomFilter == nil || w.dictBloomPopulated {
+ return nil
+ }
+
+ buffer := memory.NewResizableBuffer(w.mem)
+ defer buffer.Release()
+ buffer.Resize(dictEncoder.DictEncodedSize())
+ dictEncoder.WriteDict(buffer.Bytes())
+ return w.populateBloomFilterFromDictionary(dictEncoder, buffer.Bytes())
+}
+
+// populateBloomFilterFromDictionary hashes the PLAIN-encoded dictionary
entries
+// that are referenced by data pages. Repeated logical values therefore
contribute
+// one hash for the entire column chunk.
+func (w *columnWriter) populateBloomFilterFromDictionary(dictEncoder
encoding.DictEncoder, dictionary []byte) error {
+ if w.bloomFilter == nil || w.dictBloomPopulated {
+ return nil
+ }
+
+ referenced := dictEncoder.ReferencedDictionaryIndices()
+ if len(referenced) == 0 {
+ w.dictBloomPopulated = true
+ return nil
+ }
+
+ hasher := w.bloomFilter.Hasher()
+ insertFixedWidth := func(width int) error {
+ for _, index := range referenced {
+ start := int(index) * width
+ end := start + width
+ if index < 0 || start < 0 || end > len(dictionary) {
+ return fmt.Errorf("parquet: referenced
dictionary index %d out of bounds", index)
+ }
+
w.bloomFilter.InsertHash(hasher.Sum64(dictionary[start:end]))
+ }
+ return nil
+ }
+
+ var err error
+ switch w.descr.PhysicalType() {
+ case parquet.Types.Int32, parquet.Types.Float:
+ err = insertFixedWidth(arrow.Int32SizeBytes)
+ case parquet.Types.Int64, parquet.Types.Double:
+ err = insertFixedWidth(arrow.Int64SizeBytes)
+ case parquet.Types.Int96:
+ err = insertFixedWidth(12)
+ case parquet.Types.FixedLenByteArray:
+ err = insertFixedWidth(w.descr.TypeLength())
+ case parquet.Types.ByteArray:
+ offset := 0
+ for index := 0; index < dictEncoder.NumEntries(); index++ {
+ if len(dictionary)-offset < arrow.Uint32SizeBytes {
+ return fmt.Errorf("parquet: truncated
byte-array dictionary length at index %d", index)
+ }
+ valueLen :=
int(binary.LittleEndian.Uint32(dictionary[offset:]))
+ offset += arrow.Uint32SizeBytes
+ if valueLen < 0 || valueLen > len(dictionary)-offset {
+ return fmt.Errorf("parquet: truncated
byte-array dictionary value at index %d", index)
+ }
+ if dictEncoder.DictionaryIndexReferenced(index) {
+
w.bloomFilter.InsertHash(hasher.Sum64(dictionary[offset : offset+valueLen]))
+ }
+ offset += valueLen
+ }
+ default:
+ err = fmt.Errorf("parquet: bloom filters are not supported for
dictionary type %s", w.descr.PhysicalType())
+ }
+ if err != nil {
+ return err
+ }
+
+ w.dictBloomPopulated = true
+ return nil
+}
+
type batchWriteInfo struct {
batchNum int64
nullCount int64
diff --git a/parquet/file/column_writer_bloom_internal_test.go
b/parquet/file/column_writer_bloom_internal_test.go
new file mode 100644
index 00000000..19335eb3
--- /dev/null
+++ b/parquet/file/column_writer_bloom_internal_test.go
@@ -0,0 +1,117 @@
+// 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 (
+ "math"
+ "testing"
+
+ "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/metadata"
+ "github.com/apache/arrow-go/v18/parquet/schema"
+ "github.com/cespare/xxhash/v2"
+ "github.com/stretchr/testify/require"
+)
+
+func TestDictionaryBloomHashesUsePlainEncoding(t *testing.T) {
+ tests := []struct {
+ name string
+ node *schema.PrimitiveNode
+ putValue func(encoding.TypedEncoder)
+ expected []byte
+ }{
+ {
+ name: "int32",
+ node: schema.NewInt32Node("value",
parquet.Repetitions.Required, -1),
+ putValue: func(enc encoding.TypedEncoder) {
+
enc.(encoding.Int32Encoder).Put([]int32{0x01020304})
+ },
+ expected: []byte{0x04, 0x03, 0x02, 0x01},
+ },
+ {
+ name: "int64",
+ node: schema.NewInt64Node("value",
parquet.Repetitions.Required, -1),
+ putValue: func(enc encoding.TypedEncoder) {
+
enc.(encoding.Int64Encoder).Put([]int64{0x0102030405060708})
+ },
+ expected: []byte{0x08, 0x07, 0x06, 0x05, 0x04, 0x03,
0x02, 0x01},
+ },
+ {
+ name: "float",
+ node: schema.NewFloat32Node("value",
parquet.Repetitions.Required, -1),
+ putValue: func(enc encoding.TypedEncoder) {
+
enc.(encoding.Float32Encoder).Put([]float32{math.Float32frombits(0x01020304)})
+ },
+ expected: []byte{0x04, 0x03, 0x02, 0x01},
+ },
+ {
+ name: "double",
+ node: schema.NewFloat64Node("value",
parquet.Repetitions.Required, -1),
+ putValue: func(enc encoding.TypedEncoder) {
+
enc.(encoding.Float64Encoder).Put([]float64{math.Float64frombits(0x0102030405060708)})
+ },
+ expected: []byte{0x08, 0x07, 0x06, 0x05, 0x04, 0x03,
0x02, 0x01},
+ },
+ {
+ name: "int96",
+ node: schema.NewInt96Node("value",
parquet.Repetitions.Required, -1),
+ putValue: func(enc encoding.TypedEncoder) {
+
enc.(encoding.Int96Encoder).Put([]parquet.Int96{{0, 1, 2, 3, 4, 5, 6, 7, 8, 9,
10, 11}})
+ },
+ expected: []byte{0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11},
+ },
+ {
+ name: "byte array",
+ node: schema.NewByteArrayNode("value",
parquet.Repetitions.Required, -1),
+ putValue: func(enc encoding.TypedEncoder) {
+
enc.(encoding.ByteArrayEncoder).Put([]parquet.ByteArray{[]byte("plain")})
+ },
+ expected: []byte("plain"),
+ },
+ {
+ name: "fixed length byte array",
+ node: schema.NewFixedLenByteArrayNode("value",
parquet.Repetitions.Required, 5, -1),
+ putValue: func(enc encoding.TypedEncoder) {
+
enc.(encoding.FixedLenByteArrayEncoder).Put([]parquet.FixedLenByteArray{[]byte("fixed")})
+ },
+ expected: []byte("fixed"),
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ descr := schema.NewColumn(tt.node, 0, 0)
+ enc := encoding.NewEncoder(descr.PhysicalType(),
parquet.Encodings.PlainDict, true, descr, memory.DefaultAllocator)
+ defer enc.Release()
+ dictEnc := enc.(encoding.DictEncoder)
+ dictEnc.EnableDictionaryReferenceTracking()
+ tt.putValue(enc)
+
+ dictionary := make([]byte, dictEnc.DictEncodedSize())
+ dictEnc.WriteDict(dictionary)
+ builder := metadata.NewBloomFilter(32, 32,
memory.DefaultAllocator)
+ writer := columnWriter{descr: descr, bloomFilter:
builder}
+ require.NoError(t,
writer.populateBloomFilterFromDictionary(dictEnc, dictionary))
+
+ filter, ok := builder.(metadata.BloomFilter)
+ require.True(t, ok)
+ require.True(t,
filter.CheckHash(xxhash.Sum64(tt.expected)))
+ })
+ }
+}
diff --git a/parquet/file/column_writer_test.go
b/parquet/file/column_writer_test.go
index 7f66f79f..37491a52 100644
--- a/parquet/file/column_writer_test.go
+++ b/parquet/file/column_writer_test.go
@@ -43,6 +43,7 @@ import (
"github.com/apache/arrow-go/v18/parquet/schema"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/mock"
+ "github.com/stretchr/testify/require"
"github.com/stretchr/testify/suite"
)
@@ -170,6 +171,52 @@ func TestWriteDataPageV2NumRows(t *testing.T) {
assert.EqualValues(t, 3, wr.RowsWritten())
}
+func TestWriteDictIndicesRejectsBeforeWritingLevels(t *testing.T) {
+ root, err := schema.NewGroupNode("schema",
parquet.Repetitions.Required, schema.FieldList{
+ schema.NewInt32Node("v", parquet.Repetitions.Required, -1),
+ }, -1)
+ require.NoError(t, err)
+
+ props := parquet.NewWriterProperties(
+ parquet.WithDictionaryDefault(true),
+ parquet.WithStats(false),
+ )
+ var buf bytes.Buffer
+ w := file.NewParquetWriter(&buf, root, file.WithWriterProps(props))
+ rgw := w.AppendRowGroup()
+ cw, err := rgw.NextColumn()
+ require.NoError(t, err)
+ writer := cw.(*file.Int32ColumnChunkWriter)
+
+ dictionary := array.NewInt32Builder(memory.DefaultAllocator)
+ dictionary.AppendValues([]int32{10, 20}, nil)
+ dictValues := dictionary.NewArray()
+ dictionary.Release()
+ defer dictValues.Release()
+ indices := array.NewInt8Builder(memory.DefaultAllocator)
+ indices.Append(2)
+ invalidIndices := indices.NewArray()
+ indices.Release()
+ defer invalidIndices.Release()
+
+ dictEncoder := cw.CurrentEncoder().(encoding.DictEncoder)
+ require.NoError(t, dictEncoder.PutDictionary(dictValues))
+ require.ErrorIs(t, writer.WriteDictIndices(invalidIndices, nil, nil),
arrow.ErrInvalid)
+ assert.Equal(t, 0, writer.RowsWritten())
+
+ validBuilder := array.NewInt8Builder(memory.DefaultAllocator)
+ validBuilder.Append(0)
+ validIndices := validBuilder.NewArray()
+ validBuilder.Release()
+ defer validIndices.Release()
+ require.NoError(t, writer.WriteDictIndices(validIndices, nil, nil))
+ assert.Equal(t, 1, writer.RowsWritten())
+
+ require.NoError(t, cw.Close())
+ require.NoError(t, rgw.Close())
+ require.NoError(t, w.Close())
+}
+
func TestDataPageV2RowBoundaries(t *testing.T) {
sc := schema.NewSchema(schema.MustGroup(schema.NewGroupNode("schema",
parquet.Repetitions.Required, schema.FieldList{
schema.Must(schema.ListOf(
diff --git a/parquet/file/column_writer_types.gen.go
b/parquet/file/column_writer_types.gen.go
index bab348a3..fd5446df 100644
--- a/parquet/file/column_writer_types.gen.go
+++ b/parquet/file/column_writer_types.gen.go
@@ -45,6 +45,9 @@ type Int32ColumnChunkWriter struct {
func NewInt32ColumnChunkWriter(meta *metadata.ColumnChunkMetaDataBuilder,
pager PageWriter, useDict bool, enc parquet.Encoding, props
*parquet.WriterProperties) *Int32ColumnChunkWriter {
ret := &Int32ColumnChunkWriter{columnWriter: newColumnWriterBase(meta,
pager, useDict, enc, props)}
ret.currentEncoder =
encoding.Int32EncoderTraits.Encoder(format.Encoding(enc), useDict,
meta.Descr(), props.Allocator())
+ if useDict && ret.bloomFilter != nil {
+
ret.currentEncoder.(encoding.DictEncoder).EnableDictionaryReferenceTracking()
+ }
return ret
}
@@ -180,7 +183,6 @@ func (w *Int32ColumnChunkWriter) WriteDictIndices(indices
arrow.Array, defLevels
w.doBatches(int64(length), repLevels, func(offset, batch int64) {
info := w.maybeCalculateValidityBits(levelSliceOrNil(defLevels,
offset, batch), batch)
- w.writeLevelsSpaced(batch, levelSliceOrNil(defLevels, offset,
batch), levelSliceOrNil(repLevels, offset, batch))
writeableIndices := array.NewSlice(indices, valueOffset,
valueOffset+info.numSpaced())
defer writeableIndices.Release()
@@ -191,6 +193,8 @@ func (w *Int32ColumnChunkWriter) WriteDictIndices(indices
arrow.Array, defLevels
panic(err) // caught above
}
+ w.writeLevelsSpaced(batch, levelSliceOrNil(defLevels, offset,
batch), levelSliceOrNil(repLevels, offset, batch))
+
if err := w.commitWriteAndCheckPageLimit(batch, info.batchNum);
err != nil {
panic(err)
}
@@ -206,8 +210,7 @@ func (w *Int32ColumnChunkWriter) writeValues(values
[]int32, numNulls int64) {
if w.pageStatistics != nil {
w.pageStatistics.(*metadata.Int32Statistics).Update(values,
numNulls)
}
- if w.bloomFilter != nil {
- // TODO: optimize for Dictionary Encoding case
+ if w.bloomFilter != nil && w.currentEncoder.Encoding() !=
parquet.Encodings.PlainDict {
w.bloomFilter.InsertBulk(metadata.GetHashes(w.bloomFilter.Hasher(), values))
}
}
@@ -222,8 +225,7 @@ func (w *Int32ColumnChunkWriter)
writeValuesSpaced(spacedValues []int32, numRead
nulls := numValues - numRead
w.pageStatistics.(*metadata.Int32Statistics).UpdateSpaced(spacedValues,
validBits, validBitsOffset, nulls)
}
- if w.bloomFilter != nil {
- // TODO: optimize for Dictionary Encoding case
+ if w.bloomFilter != nil && w.currentEncoder.Encoding() !=
parquet.Encodings.PlainDict {
w.bloomFilter.InsertBulk(metadata.GetSpacedHashes(w.bloomFilter.Hasher(),
numRead, spacedValues, validBits, validBitsOffset))
}
}
@@ -285,6 +287,8 @@ func (w *Int32ColumnChunkWriter) FallbackToPlain() {
if err := w.drainBufferedDataPages(); err != nil {
panic(err)
}
+ } else if err := w.populateBloomFilterFromEncoder(dictEnc); err != nil {
+ panic(err)
}
if err := dictEnc.FallBackTo(plainEnc); err != nil {
@@ -312,6 +316,9 @@ type Int64ColumnChunkWriter struct {
func NewInt64ColumnChunkWriter(meta *metadata.ColumnChunkMetaDataBuilder,
pager PageWriter, useDict bool, enc parquet.Encoding, props
*parquet.WriterProperties) *Int64ColumnChunkWriter {
ret := &Int64ColumnChunkWriter{columnWriter: newColumnWriterBase(meta,
pager, useDict, enc, props)}
ret.currentEncoder =
encoding.Int64EncoderTraits.Encoder(format.Encoding(enc), useDict,
meta.Descr(), props.Allocator())
+ if useDict && ret.bloomFilter != nil {
+
ret.currentEncoder.(encoding.DictEncoder).EnableDictionaryReferenceTracking()
+ }
return ret
}
@@ -447,7 +454,6 @@ func (w *Int64ColumnChunkWriter) WriteDictIndices(indices
arrow.Array, defLevels
w.doBatches(int64(length), repLevels, func(offset, batch int64) {
info := w.maybeCalculateValidityBits(levelSliceOrNil(defLevels,
offset, batch), batch)
- w.writeLevelsSpaced(batch, levelSliceOrNil(defLevels, offset,
batch), levelSliceOrNil(repLevels, offset, batch))
writeableIndices := array.NewSlice(indices, valueOffset,
valueOffset+info.numSpaced())
defer writeableIndices.Release()
@@ -458,6 +464,8 @@ func (w *Int64ColumnChunkWriter) WriteDictIndices(indices
arrow.Array, defLevels
panic(err) // caught above
}
+ w.writeLevelsSpaced(batch, levelSliceOrNil(defLevels, offset,
batch), levelSliceOrNil(repLevels, offset, batch))
+
if err := w.commitWriteAndCheckPageLimit(batch, info.batchNum);
err != nil {
panic(err)
}
@@ -473,8 +481,7 @@ func (w *Int64ColumnChunkWriter) writeValues(values
[]int64, numNulls int64) {
if w.pageStatistics != nil {
w.pageStatistics.(*metadata.Int64Statistics).Update(values,
numNulls)
}
- if w.bloomFilter != nil {
- // TODO: optimize for Dictionary Encoding case
+ if w.bloomFilter != nil && w.currentEncoder.Encoding() !=
parquet.Encodings.PlainDict {
w.bloomFilter.InsertBulk(metadata.GetHashes(w.bloomFilter.Hasher(), values))
}
}
@@ -489,8 +496,7 @@ func (w *Int64ColumnChunkWriter)
writeValuesSpaced(spacedValues []int64, numRead
nulls := numValues - numRead
w.pageStatistics.(*metadata.Int64Statistics).UpdateSpaced(spacedValues,
validBits, validBitsOffset, nulls)
}
- if w.bloomFilter != nil {
- // TODO: optimize for Dictionary Encoding case
+ if w.bloomFilter != nil && w.currentEncoder.Encoding() !=
parquet.Encodings.PlainDict {
w.bloomFilter.InsertBulk(metadata.GetSpacedHashes(w.bloomFilter.Hasher(),
numRead, spacedValues, validBits, validBitsOffset))
}
}
@@ -552,6 +558,8 @@ func (w *Int64ColumnChunkWriter) FallbackToPlain() {
if err := w.drainBufferedDataPages(); err != nil {
panic(err)
}
+ } else if err := w.populateBloomFilterFromEncoder(dictEnc); err != nil {
+ panic(err)
}
if err := dictEnc.FallBackTo(plainEnc); err != nil {
@@ -579,6 +587,9 @@ type Int96ColumnChunkWriter struct {
func NewInt96ColumnChunkWriter(meta *metadata.ColumnChunkMetaDataBuilder,
pager PageWriter, useDict bool, enc parquet.Encoding, props
*parquet.WriterProperties) *Int96ColumnChunkWriter {
ret := &Int96ColumnChunkWriter{columnWriter: newColumnWriterBase(meta,
pager, useDict, enc, props)}
ret.currentEncoder =
encoding.Int96EncoderTraits.Encoder(format.Encoding(enc), useDict,
meta.Descr(), props.Allocator())
+ if useDict && ret.bloomFilter != nil {
+
ret.currentEncoder.(encoding.DictEncoder).EnableDictionaryReferenceTracking()
+ }
return ret
}
@@ -714,7 +725,6 @@ func (w *Int96ColumnChunkWriter) WriteDictIndices(indices
arrow.Array, defLevels
w.doBatches(int64(length), repLevels, func(offset, batch int64) {
info := w.maybeCalculateValidityBits(levelSliceOrNil(defLevels,
offset, batch), batch)
- w.writeLevelsSpaced(batch, levelSliceOrNil(defLevels, offset,
batch), levelSliceOrNil(repLevels, offset, batch))
writeableIndices := array.NewSlice(indices, valueOffset,
valueOffset+info.numSpaced())
defer writeableIndices.Release()
@@ -725,6 +735,8 @@ func (w *Int96ColumnChunkWriter) WriteDictIndices(indices
arrow.Array, defLevels
panic(err) // caught above
}
+ w.writeLevelsSpaced(batch, levelSliceOrNil(defLevels, offset,
batch), levelSliceOrNil(repLevels, offset, batch))
+
if err := w.commitWriteAndCheckPageLimit(batch, info.batchNum);
err != nil {
panic(err)
}
@@ -740,8 +752,7 @@ func (w *Int96ColumnChunkWriter) writeValues(values
[]parquet.Int96, numNulls in
if w.pageStatistics != nil {
w.pageStatistics.(*metadata.Int96Statistics).Update(values,
numNulls)
}
- if w.bloomFilter != nil {
- // TODO: optimize for Dictionary Encoding case
+ if w.bloomFilter != nil && w.currentEncoder.Encoding() !=
parquet.Encodings.PlainDict {
w.bloomFilter.InsertBulk(metadata.GetHashes(w.bloomFilter.Hasher(), values))
}
}
@@ -756,8 +767,7 @@ func (w *Int96ColumnChunkWriter)
writeValuesSpaced(spacedValues []parquet.Int96,
nulls := numValues - numRead
w.pageStatistics.(*metadata.Int96Statistics).UpdateSpaced(spacedValues,
validBits, validBitsOffset, nulls)
}
- if w.bloomFilter != nil {
- // TODO: optimize for Dictionary Encoding case
+ if w.bloomFilter != nil && w.currentEncoder.Encoding() !=
parquet.Encodings.PlainDict {
w.bloomFilter.InsertBulk(metadata.GetSpacedHashes(w.bloomFilter.Hasher(),
numRead, spacedValues, validBits, validBitsOffset))
}
}
@@ -819,6 +829,8 @@ func (w *Int96ColumnChunkWriter) FallbackToPlain() {
if err := w.drainBufferedDataPages(); err != nil {
panic(err)
}
+ } else if err := w.populateBloomFilterFromEncoder(dictEnc); err != nil {
+ panic(err)
}
if err := dictEnc.FallBackTo(plainEnc); err != nil {
@@ -846,6 +858,9 @@ type Float32ColumnChunkWriter struct {
func NewFloat32ColumnChunkWriter(meta *metadata.ColumnChunkMetaDataBuilder,
pager PageWriter, useDict bool, enc parquet.Encoding, props
*parquet.WriterProperties) *Float32ColumnChunkWriter {
ret := &Float32ColumnChunkWriter{columnWriter:
newColumnWriterBase(meta, pager, useDict, enc, props)}
ret.currentEncoder =
encoding.Float32EncoderTraits.Encoder(format.Encoding(enc), useDict,
meta.Descr(), props.Allocator())
+ if useDict && ret.bloomFilter != nil {
+
ret.currentEncoder.(encoding.DictEncoder).EnableDictionaryReferenceTracking()
+ }
return ret
}
@@ -981,7 +996,6 @@ func (w *Float32ColumnChunkWriter) WriteDictIndices(indices
arrow.Array, defLeve
w.doBatches(int64(length), repLevels, func(offset, batch int64) {
info := w.maybeCalculateValidityBits(levelSliceOrNil(defLevels,
offset, batch), batch)
- w.writeLevelsSpaced(batch, levelSliceOrNil(defLevels, offset,
batch), levelSliceOrNil(repLevels, offset, batch))
writeableIndices := array.NewSlice(indices, valueOffset,
valueOffset+info.numSpaced())
defer writeableIndices.Release()
@@ -992,6 +1006,8 @@ func (w *Float32ColumnChunkWriter)
WriteDictIndices(indices arrow.Array, defLeve
panic(err) // caught above
}
+ w.writeLevelsSpaced(batch, levelSliceOrNil(defLevels, offset,
batch), levelSliceOrNil(repLevels, offset, batch))
+
if err := w.commitWriteAndCheckPageLimit(batch, info.batchNum);
err != nil {
panic(err)
}
@@ -1007,8 +1023,7 @@ func (w *Float32ColumnChunkWriter) writeValues(values
[]float32, numNulls int64)
if w.pageStatistics != nil {
w.pageStatistics.(*metadata.Float32Statistics).Update(values,
numNulls)
}
- if w.bloomFilter != nil {
- // TODO: optimize for Dictionary Encoding case
+ if w.bloomFilter != nil && w.currentEncoder.Encoding() !=
parquet.Encodings.PlainDict {
w.bloomFilter.InsertBulk(metadata.GetHashes(w.bloomFilter.Hasher(), values))
}
}
@@ -1023,8 +1038,7 @@ func (w *Float32ColumnChunkWriter)
writeValuesSpaced(spacedValues []float32, num
nulls := numValues - numRead
w.pageStatistics.(*metadata.Float32Statistics).UpdateSpaced(spacedValues,
validBits, validBitsOffset, nulls)
}
- if w.bloomFilter != nil {
- // TODO: optimize for Dictionary Encoding case
+ if w.bloomFilter != nil && w.currentEncoder.Encoding() !=
parquet.Encodings.PlainDict {
w.bloomFilter.InsertBulk(metadata.GetSpacedHashes(w.bloomFilter.Hasher(),
numRead, spacedValues, validBits, validBitsOffset))
}
}
@@ -1086,6 +1100,8 @@ func (w *Float32ColumnChunkWriter) FallbackToPlain() {
if err := w.drainBufferedDataPages(); err != nil {
panic(err)
}
+ } else if err := w.populateBloomFilterFromEncoder(dictEnc); err != nil {
+ panic(err)
}
if err := dictEnc.FallBackTo(plainEnc); err != nil {
@@ -1113,6 +1129,9 @@ type Float64ColumnChunkWriter struct {
func NewFloat64ColumnChunkWriter(meta *metadata.ColumnChunkMetaDataBuilder,
pager PageWriter, useDict bool, enc parquet.Encoding, props
*parquet.WriterProperties) *Float64ColumnChunkWriter {
ret := &Float64ColumnChunkWriter{columnWriter:
newColumnWriterBase(meta, pager, useDict, enc, props)}
ret.currentEncoder =
encoding.Float64EncoderTraits.Encoder(format.Encoding(enc), useDict,
meta.Descr(), props.Allocator())
+ if useDict && ret.bloomFilter != nil {
+
ret.currentEncoder.(encoding.DictEncoder).EnableDictionaryReferenceTracking()
+ }
return ret
}
@@ -1248,7 +1267,6 @@ func (w *Float64ColumnChunkWriter)
WriteDictIndices(indices arrow.Array, defLeve
w.doBatches(int64(length), repLevels, func(offset, batch int64) {
info := w.maybeCalculateValidityBits(levelSliceOrNil(defLevels,
offset, batch), batch)
- w.writeLevelsSpaced(batch, levelSliceOrNil(defLevels, offset,
batch), levelSliceOrNil(repLevels, offset, batch))
writeableIndices := array.NewSlice(indices, valueOffset,
valueOffset+info.numSpaced())
defer writeableIndices.Release()
@@ -1259,6 +1277,8 @@ func (w *Float64ColumnChunkWriter)
WriteDictIndices(indices arrow.Array, defLeve
panic(err) // caught above
}
+ w.writeLevelsSpaced(batch, levelSliceOrNil(defLevels, offset,
batch), levelSliceOrNil(repLevels, offset, batch))
+
if err := w.commitWriteAndCheckPageLimit(batch, info.batchNum);
err != nil {
panic(err)
}
@@ -1274,8 +1294,7 @@ func (w *Float64ColumnChunkWriter) writeValues(values
[]float64, numNulls int64)
if w.pageStatistics != nil {
w.pageStatistics.(*metadata.Float64Statistics).Update(values,
numNulls)
}
- if w.bloomFilter != nil {
- // TODO: optimize for Dictionary Encoding case
+ if w.bloomFilter != nil && w.currentEncoder.Encoding() !=
parquet.Encodings.PlainDict {
w.bloomFilter.InsertBulk(metadata.GetHashes(w.bloomFilter.Hasher(), values))
}
}
@@ -1290,8 +1309,7 @@ func (w *Float64ColumnChunkWriter)
writeValuesSpaced(spacedValues []float64, num
nulls := numValues - numRead
w.pageStatistics.(*metadata.Float64Statistics).UpdateSpaced(spacedValues,
validBits, validBitsOffset, nulls)
}
- if w.bloomFilter != nil {
- // TODO: optimize for Dictionary Encoding case
+ if w.bloomFilter != nil && w.currentEncoder.Encoding() !=
parquet.Encodings.PlainDict {
w.bloomFilter.InsertBulk(metadata.GetSpacedHashes(w.bloomFilter.Hasher(),
numRead, spacedValues, validBits, validBitsOffset))
}
}
@@ -1353,6 +1371,8 @@ func (w *Float64ColumnChunkWriter) FallbackToPlain() {
if err := w.drainBufferedDataPages(); err != nil {
panic(err)
}
+ } else if err := w.populateBloomFilterFromEncoder(dictEnc); err != nil {
+ panic(err)
}
if err := dictEnc.FallBackTo(plainEnc); err != nil {
@@ -1383,6 +1403,9 @@ func NewBooleanColumnChunkWriter(meta
*metadata.ColumnChunkMetaDataBuilder, page
}
ret := &BooleanColumnChunkWriter{columnWriter:
newColumnWriterBase(meta, pager, useDict, enc, props)}
ret.currentEncoder =
encoding.BooleanEncoderTraits.Encoder(format.Encoding(enc), useDict,
meta.Descr(), props.Allocator())
+ if useDict && ret.bloomFilter != nil {
+
ret.currentEncoder.(encoding.DictEncoder).EnableDictionaryReferenceTracking()
+ }
return ret
}
@@ -1598,7 +1621,6 @@ func (w *BooleanColumnChunkWriter)
WriteDictIndices(indices arrow.Array, defLeve
w.doBatches(int64(length), repLevels, func(offset, batch int64) {
info := w.maybeCalculateValidityBits(levelSliceOrNil(defLevels,
offset, batch), batch)
- w.writeLevelsSpaced(batch, levelSliceOrNil(defLevels, offset,
batch), levelSliceOrNil(repLevels, offset, batch))
writeableIndices := array.NewSlice(indices, valueOffset,
valueOffset+info.numSpaced())
defer writeableIndices.Release()
@@ -1609,6 +1631,8 @@ func (w *BooleanColumnChunkWriter)
WriteDictIndices(indices arrow.Array, defLeve
panic(err) // caught above
}
+ w.writeLevelsSpaced(batch, levelSliceOrNil(defLevels, offset,
batch), levelSliceOrNil(repLevels, offset, batch))
+
if err := w.commitWriteAndCheckPageLimit(batch, info.batchNum);
err != nil {
panic(err)
}
@@ -1624,8 +1648,7 @@ func (w *BooleanColumnChunkWriter) writeValues(values
[]bool, numNulls int64) {
if w.pageStatistics != nil {
w.pageStatistics.(*metadata.BooleanStatistics).Update(values,
numNulls)
}
- if w.bloomFilter != nil {
- // TODO: optimize for Dictionary Encoding case
+ if w.bloomFilter != nil && w.currentEncoder.Encoding() !=
parquet.Encodings.PlainDict {
w.bloomFilter.InsertBulk(metadata.GetHashes(w.bloomFilter.Hasher(), values))
}
}
@@ -1640,8 +1663,7 @@ func (w *BooleanColumnChunkWriter)
writeValuesSpaced(spacedValues []bool, numRea
nulls := numValues - numRead
w.pageStatistics.(*metadata.BooleanStatistics).UpdateSpaced(spacedValues,
validBits, validBitsOffset, nulls)
}
- if w.bloomFilter != nil {
- // TODO: optimize for Dictionary Encoding case
+ if w.bloomFilter != nil && w.currentEncoder.Encoding() !=
parquet.Encodings.PlainDict {
w.bloomFilter.InsertBulk(metadata.GetSpacedHashes(w.bloomFilter.Hasher(),
numRead, spacedValues, validBits, validBitsOffset))
}
}
@@ -1766,6 +1788,8 @@ func (w *BooleanColumnChunkWriter) FallbackToPlain() {
if err := w.drainBufferedDataPages(); err != nil {
panic(err)
}
+ } else if err := w.populateBloomFilterFromEncoder(dictEnc); err != nil {
+ panic(err)
}
if err := dictEnc.FallBackTo(plainEnc); err != nil {
@@ -1793,6 +1817,9 @@ type ByteArrayColumnChunkWriter struct {
func NewByteArrayColumnChunkWriter(meta *metadata.ColumnChunkMetaDataBuilder,
pager PageWriter, useDict bool, enc parquet.Encoding, props
*parquet.WriterProperties) *ByteArrayColumnChunkWriter {
ret := &ByteArrayColumnChunkWriter{columnWriter:
newColumnWriterBase(meta, pager, useDict, enc, props)}
ret.currentEncoder =
encoding.ByteArrayEncoderTraits.Encoder(format.Encoding(enc), useDict,
meta.Descr(), props.Allocator())
+ if useDict && ret.bloomFilter != nil {
+
ret.currentEncoder.(encoding.DictEncoder).EnableDictionaryReferenceTracking()
+ }
return ret
}
@@ -2038,7 +2065,6 @@ func (w *ByteArrayColumnChunkWriter)
WriteDictIndices(indices arrow.Array, defLe
w.doBatches(int64(length), repLevels, func(offset, batch int64) {
info := w.maybeCalculateValidityBits(levelSliceOrNil(defLevels,
offset, batch), batch)
- w.writeLevelsSpaced(batch, levelSliceOrNil(defLevels, offset,
batch), levelSliceOrNil(repLevels, offset, batch))
writeableIndices := array.NewSlice(indices, valueOffset,
valueOffset+info.numSpaced())
defer writeableIndices.Release()
@@ -2049,6 +2075,8 @@ func (w *ByteArrayColumnChunkWriter)
WriteDictIndices(indices arrow.Array, defLe
panic(err) // caught above
}
+ w.writeLevelsSpaced(batch, levelSliceOrNil(defLevels, offset,
batch), levelSliceOrNil(repLevels, offset, batch))
+
if err := w.commitWriteAndCheckPageLimit(batch, info.batchNum);
err != nil {
panic(err)
}
@@ -2064,8 +2092,7 @@ func (w *ByteArrayColumnChunkWriter) writeValues(values
[]parquet.ByteArray, num
if w.pageStatistics != nil {
w.pageStatistics.(*metadata.ByteArrayStatistics).Update(values,
numNulls)
}
- if w.bloomFilter != nil {
- // TODO: optimize for Dictionary Encoding case
+ if w.bloomFilter != nil && w.currentEncoder.Encoding() !=
parquet.Encodings.PlainDict {
w.bloomFilter.InsertBulk(metadata.GetHashes(w.bloomFilter.Hasher(), values))
}
}
@@ -2080,8 +2107,7 @@ func (w *ByteArrayColumnChunkWriter)
writeValuesSpaced(spacedValues []parquet.By
nulls := numValues - numRead
w.pageStatistics.(*metadata.ByteArrayStatistics).UpdateSpaced(spacedValues,
validBits, validBitsOffset, nulls)
}
- if w.bloomFilter != nil {
- // TODO: optimize for Dictionary Encoding case
+ if w.bloomFilter != nil && w.currentEncoder.Encoding() !=
parquet.Encodings.PlainDict {
w.bloomFilter.InsertBulk(metadata.GetSpacedHashes(w.bloomFilter.Hasher(),
numRead, spacedValues, validBits, validBitsOffset))
}
}
@@ -2143,6 +2169,8 @@ func (w *ByteArrayColumnChunkWriter) FallbackToPlain() {
if err := w.drainBufferedDataPages(); err != nil {
panic(err)
}
+ } else if err := w.populateBloomFilterFromEncoder(dictEnc); err != nil {
+ panic(err)
}
if err := dictEnc.FallBackTo(plainEnc); err != nil {
@@ -2170,6 +2198,9 @@ type FixedLenByteArrayColumnChunkWriter struct {
func NewFixedLenByteArrayColumnChunkWriter(meta
*metadata.ColumnChunkMetaDataBuilder, pager PageWriter, useDict bool, enc
parquet.Encoding, props *parquet.WriterProperties)
*FixedLenByteArrayColumnChunkWriter {
ret := &FixedLenByteArrayColumnChunkWriter{columnWriter:
newColumnWriterBase(meta, pager, useDict, enc, props)}
ret.currentEncoder =
encoding.FixedLenByteArrayEncoderTraits.Encoder(format.Encoding(enc), useDict,
meta.Descr(), props.Allocator())
+ if useDict && ret.bloomFilter != nil {
+
ret.currentEncoder.(encoding.DictEncoder).EnableDictionaryReferenceTracking()
+ }
return ret
}
@@ -2415,7 +2446,6 @@ func (w *FixedLenByteArrayColumnChunkWriter)
WriteDictIndices(indices arrow.Arra
w.doBatches(int64(length), repLevels, func(offset, batch int64) {
info := w.maybeCalculateValidityBits(levelSliceOrNil(defLevels,
offset, batch), batch)
- w.writeLevelsSpaced(batch, levelSliceOrNil(defLevels, offset,
batch), levelSliceOrNil(repLevels, offset, batch))
writeableIndices := array.NewSlice(indices, valueOffset,
valueOffset+info.numSpaced())
defer writeableIndices.Release()
@@ -2426,6 +2456,8 @@ func (w *FixedLenByteArrayColumnChunkWriter)
WriteDictIndices(indices arrow.Arra
panic(err) // caught above
}
+ w.writeLevelsSpaced(batch, levelSliceOrNil(defLevels, offset,
batch), levelSliceOrNil(repLevels, offset, batch))
+
if err := w.commitWriteAndCheckPageLimit(batch, info.batchNum);
err != nil {
panic(err)
}
@@ -2445,8 +2477,7 @@ func (w *FixedLenByteArrayColumnChunkWriter)
writeValues(values []parquet.FixedL
w.pageStatistics.(*metadata.FixedLenByteArrayStatistics).Update(values,
numNulls)
}
}
- if w.bloomFilter != nil {
- // TODO: optimize for Dictionary Encoding case
+ if w.bloomFilter != nil && w.currentEncoder.Encoding() !=
parquet.Encodings.PlainDict {
w.bloomFilter.InsertBulk(metadata.GetHashes(w.bloomFilter.Hasher(), values))
}
}
@@ -2465,8 +2496,7 @@ func (w *FixedLenByteArrayColumnChunkWriter)
writeValuesSpaced(spacedValues []pa
w.pageStatistics.(*metadata.FixedLenByteArrayStatistics).UpdateSpaced(spacedValues,
validBits, validBitsOffset, nulls)
}
}
- if w.bloomFilter != nil {
- // TODO: optimize for Dictionary Encoding case
+ if w.bloomFilter != nil && w.currentEncoder.Encoding() !=
parquet.Encodings.PlainDict {
w.bloomFilter.InsertBulk(metadata.GetSpacedHashes(w.bloomFilter.Hasher(),
numRead, spacedValues, validBits, validBitsOffset))
}
}
@@ -2528,6 +2558,8 @@ func (w *FixedLenByteArrayColumnChunkWriter)
FallbackToPlain() {
if err := w.drainBufferedDataPages(); err != nil {
panic(err)
}
+ } else if err := w.populateBloomFilterFromEncoder(dictEnc); err != nil {
+ panic(err)
}
if err := dictEnc.FallBackTo(plainEnc); err != nil {
diff --git a/parquet/file/column_writer_types.gen.go.tmpl
b/parquet/file/column_writer_types.gen.go.tmpl
index 6988c468..ba898bec 100644
--- a/parquet/file/column_writer_types.gen.go.tmpl
+++ b/parquet/file/column_writer_types.gen.go.tmpl
@@ -49,6 +49,9 @@ func New{{.Name}}ColumnChunkWriter(meta
*metadata.ColumnChunkMetaDataBuilder, pa
{{- end}}
ret := &{{.Name}}ColumnChunkWriter{columnWriter: newColumnWriterBase(meta,
pager, useDict, enc, props)}
ret.currentEncoder =
encoding.{{.Name}}EncoderTraits.Encoder(format.Encoding(enc), useDict,
meta.Descr(), props.Allocator())
+ if useDict && ret.bloomFilter != nil {
+
ret.currentEncoder.(encoding.DictEncoder).EnableDictionaryReferenceTracking()
+ }
return ret
}
@@ -428,7 +431,6 @@ func (w *{{.Name}}ColumnChunkWriter)
WriteDictIndices(indices arrow.Array, defLe
w.doBatches(int64(length), repLevels, func(offset, batch int64) {
info := w.maybeCalculateValidityBits(levelSliceOrNil(defLevels, offset,
batch), batch)
- w.writeLevelsSpaced(batch, levelSliceOrNil(defLevels, offset, batch),
levelSliceOrNil(repLevels, offset, batch))
writeableIndices := array.NewSlice(indices, valueOffset,
valueOffset+info.numSpaced())
defer writeableIndices.Release()
@@ -439,6 +441,8 @@ func (w *{{.Name}}ColumnChunkWriter)
WriteDictIndices(indices arrow.Array, defLe
panic(err) // caught above
}
+ w.writeLevelsSpaced(batch, levelSliceOrNil(defLevels, offset, batch),
levelSliceOrNil(repLevels, offset, batch))
+
if err := w.commitWriteAndCheckPageLimit(batch, info.batchNum); err != nil
{
panic(err)
}
@@ -462,8 +466,7 @@ func (w *{{.Name}}ColumnChunkWriter) writeValues(values
[]{{.name}}, numNulls in
}
{{- end}}
}
- if w.bloomFilter != nil {
- // TODO: optimize for Dictionary Encoding case
+ if w.bloomFilter != nil && w.currentEncoder.Encoding() !=
parquet.Encodings.PlainDict {
w.bloomFilter.InsertBulk(metadata.GetHashes(w.bloomFilter.Hasher(),
values))
}
}
@@ -486,8 +489,7 @@ func (w *{{.Name}}ColumnChunkWriter)
writeValuesSpaced(spacedValues []{{.name}},
}
{{- end}}
}
- if w.bloomFilter != nil {
- // TODO: optimize for Dictionary Encoding case
+ if w.bloomFilter != nil && w.currentEncoder.Encoding() !=
parquet.Encodings.PlainDict {
w.bloomFilter.InsertBulk(metadata.GetSpacedHashes(w.bloomFilter.Hasher(),
numRead, spacedValues, validBits, validBitsOffset))
}
}
@@ -614,6 +616,8 @@ func (w *{{.Name}}ColumnChunkWriter) FallbackToPlain() {
if err := w.drainBufferedDataPages(); err != nil {
panic(err)
}
+ } else if err := w.populateBloomFilterFromEncoder(dictEnc); err != nil {
+ panic(err)
}
if err := dictEnc.FallBackTo(plainEnc); err != nil {
diff --git a/parquet/file/file_writer_test.go b/parquet/file/file_writer_test.go
index 9e1abd08..2a28abde 100644
--- a/parquet/file/file_writer_test.go
+++ b/parquet/file/file_writer_test.go
@@ -1211,6 +1211,87 @@ func TestWriteBloomFilters(t *testing.T) {
assert.False(t, byteArrayFilter.Check(parquet.ByteArray("baz")))
}
+func TestWriteDictionaryBloomFilterAfterFallback(t *testing.T) {
+ values := []parquet.ByteArray{
+ parquet.ByteArray("alpha"),
+ parquet.ByteArray("beta"),
+ parquet.ByteArray("alpha"),
+ }
+ props := parquet.NewWriterProperties(
+ parquet.WithDictionaryDefault(true),
+ parquet.WithDictionaryPageSizeLimit(1),
+ parquet.WithBloomFilterEnabledFor("values", true),
+ parquet.WithBloomFilterNDVFor("values", 2),
+ )
+ field := schema.NewByteArrayNode("values",
parquet.Repetitions.Required, -1)
+ sc, err := schema.NewGroupNode("schema", parquet.Repetitions.Required,
schema.FieldList{field}, -1)
+ require.NoError(t, err)
+
+ sink := encoding.NewBufferWriter(0, memory.DefaultAllocator)
+ defer sink.Release()
+ writer := file.NewParquetWriter(sink, sc, file.WithWriterProps(props))
+ rowGroup := writer.AppendRowGroup()
+ column, err := rowGroup.NextColumn()
+ require.NoError(t, err)
+ _, err = column.(*file.ByteArrayColumnChunkWriter).WriteBatch(values,
nil, nil)
+ require.NoError(t, err)
+ require.NoError(t, column.Close())
+ require.NoError(t, rowGroup.Close())
+ require.NoError(t, writer.Close())
+
+ reader, err := file.NewParquetReader(bytes.NewReader(sink.Bytes()))
+ require.NoError(t, err)
+ defer reader.Close()
+ bloomReader := reader.GetBloomFilterReader()
+ rowGroupBloom, err := bloomReader.RowGroup(0)
+ require.NoError(t, err)
+ require.NoError(t, rowGroupBloom.VisitColumnBloomFilter(0, func(filter
metadata.BloomFilter) error {
+ typedFilter :=
metadata.TypedBloomFilter[parquet.ByteArray]{BloomFilter: filter}
+ assert.True(t, typedFilter.Check(parquet.ByteArray("alpha")))
+ assert.True(t, typedFilter.Check(parquet.ByteArray("beta")))
+ return nil
+ }))
+}
+
+func TestWriteDictionaryBloomFilterAcrossPages(t *testing.T) {
+ values := []int32{7, 42, 7, 99, 42, 7}
+ props := parquet.NewWriterProperties(
+ parquet.WithDictionaryDefault(true),
+ parquet.WithDataPageSize(1),
+ parquet.WithBatchSize(2),
+ parquet.WithBloomFilterEnabledFor("values", true),
+ parquet.WithBloomFilterNDVFor("values", 3),
+ )
+ field := schema.NewInt32Node("values", parquet.Repetitions.Required, -1)
+ sc, err := schema.NewGroupNode("schema", parquet.Repetitions.Required,
schema.FieldList{field}, -1)
+ require.NoError(t, err)
+
+ sink := encoding.NewBufferWriter(0, memory.DefaultAllocator)
+ defer sink.Release()
+ writer := file.NewParquetWriter(sink, sc, file.WithWriterProps(props))
+ rowGroup := writer.AppendRowGroup()
+ column, err := rowGroup.NextColumn()
+ require.NoError(t, err)
+ _, err = column.(*file.Int32ColumnChunkWriter).WriteBatch(values, nil,
nil)
+ require.NoError(t, err)
+ require.NoError(t, column.Close())
+ require.NoError(t, rowGroup.Close())
+ require.NoError(t, writer.Close())
+
+ reader, err := file.NewParquetReader(bytes.NewReader(sink.Bytes()))
+ require.NoError(t, err)
+ defer reader.Close()
+ rowGroupBloom, err := reader.GetBloomFilterReader().RowGroup(0)
+ require.NoError(t, err)
+ require.NoError(t, rowGroupBloom.VisitColumnBloomFilter(0, func(filter
metadata.BloomFilter) error {
+ typedFilter := metadata.TypedBloomFilter[int32]{BloomFilter:
filter}
+ assert.True(t, typedFilter.Check(7))
+ assert.True(t, typedFilter.Check(42))
+ assert.True(t, typedFilter.Check(99))
+ return nil
+ }))
+}
+
// TestBufferedStreamDictionaryCompressed tests the fix for issue #619
// where BufferedStreamEnabled=true with dictionary encoding and compression
// caused "dict spaced eof exception" and "snappy: corrupt input" errors.
diff --git a/parquet/file/writer_performance_test.go
b/parquet/file/writer_performance_test.go
index f3283ae6..0d823769 100644
--- a/parquet/file/writer_performance_test.go
+++ b/parquet/file/writer_performance_test.go
@@ -18,13 +18,93 @@ package file_test
import (
"bytes"
+ "fmt"
"testing"
+ "github.com/apache/arrow-go/v18/arrow"
"github.com/apache/arrow-go/v18/parquet"
"github.com/apache/arrow-go/v18/parquet/file"
"github.com/apache/arrow-go/v18/parquet/schema"
)
+func BenchmarkWriteDictionaryBloomFilter(b *testing.B) {
+ const numValues = 100_000
+
+ tests := []struct {
+ name string
+ cardinality int
+ }{
+ {name: "cardinality=1", cardinality: 1},
+ {name: "cardinality=10", cardinality: 10},
+ {name: "cardinality=100", cardinality: 100},
+ {name: "cardinality=1000", cardinality: 1_000},
+ {name: "cardinality=10000", cardinality: 10_000},
+ }
+
+ for _, tt := range tests {
+ b.Run("int32/"+tt.name, func(b *testing.B) {
+ values := make([]int32, numValues)
+ for i := range values {
+ values[i] = int32(i % tt.cardinality)
+ }
+ sc :=
schema.NewSchema(schema.MustGroup(schema.NewGroupNode("schema",
parquet.Repetitions.Required, schema.FieldList{
+ schema.NewInt32Node("values",
parquet.Repetitions.Required, -1),
+ }, -1)))
+ props := parquet.NewWriterProperties(
+ parquet.WithDictionaryDefault(true),
+ parquet.WithStats(false),
+ parquet.WithBloomFilterEnabledFor("values",
true),
+ parquet.WithBloomFilterNDVFor("values",
int64(tt.cardinality)),
+ )
+
+ b.ReportAllocs()
+ b.SetBytes(int64(numValues * arrow.Int32SizeBytes))
+ for range b.N {
+ var output bytes.Buffer
+ writer := file.NewParquetWriter(&output,
sc.Root(), file.WithWriterProps(props))
+ rowGroup := writer.AppendRowGroup()
+ column, _ := rowGroup.NextColumn()
+ _, _ =
column.(*file.Int32ColumnChunkWriter).WriteBatch(values, nil, nil)
+ _ = column.Close()
+ _ = rowGroup.Close()
+ _ = writer.Close()
+ }
+ })
+
+ b.Run("byte_array/"+tt.name, func(b *testing.B) {
+ dictionary := make([]parquet.ByteArray, tt.cardinality)
+ for i := range dictionary {
+ dictionary[i] =
parquet.ByteArray(fmt.Sprintf("value-%08d", i))
+ }
+ values := make([]parquet.ByteArray, numValues)
+ for i := range values {
+ values[i] = dictionary[i%tt.cardinality]
+ }
+ sc :=
schema.NewSchema(schema.MustGroup(schema.NewGroupNode("schema",
parquet.Repetitions.Required, schema.FieldList{
+ schema.NewByteArrayNode("values",
parquet.Repetitions.Required, -1),
+ }, -1)))
+ props := parquet.NewWriterProperties(
+ parquet.WithDictionaryDefault(true),
+ parquet.WithStats(false),
+ parquet.WithBloomFilterEnabledFor("values",
true),
+ parquet.WithBloomFilterNDVFor("values",
int64(tt.cardinality)),
+ )
+
+ b.ReportAllocs()
+ for range b.N {
+ var output bytes.Buffer
+ writer := file.NewParquetWriter(&output,
sc.Root(), file.WithWriterProps(props))
+ rowGroup := writer.AppendRowGroup()
+ column, _ := rowGroup.NextColumn()
+ _, _ =
column.(*file.ByteArrayColumnChunkWriter).WriteBatch(values, nil, nil)
+ _ = column.Close()
+ _ = rowGroup.Close()
+ _ = writer.Close()
+ }
+ })
+ }
+}
+
// Benchmark writing small ByteArray values (typical case)
// This tests the common scenario where values are small (< 1KB)
func BenchmarkWriteSmallByteArrayValues(b *testing.B) {
diff --git a/parquet/internal/encoding/encoder.go
b/parquet/internal/encoding/encoder.go
index e6561d69..58851b30 100644
--- a/parquet/internal/encoding/encoder.go
+++ b/parquet/internal/encoding/encoder.go
@@ -107,10 +107,13 @@ func (e *encoder) Reset() { e.sink.Reset(0) }
type dictEncoder struct {
encoder
- dictEncodedSize int
- idxBuffer *memory.Buffer
- idxValues []int32
- memo MemoTable
+ dictEncodedSize int
+ idxBuffer *memory.Buffer
+ idxValues []int32
+ memo MemoTable
+ trackReferences bool
+ referencedBitmap []byte
+ referencedIndices []int32
// rawDataSize is the number of bytes of input values observed since
// the last page flush. Mirrors parquet-mr's rawDataByteSize and is
@@ -140,6 +143,8 @@ func (d *dictEncoder) Reset() {
d.idxValues = d.idxValues[:0]
d.idxBuffer.ResizeNoShrink(0)
d.rawDataSize = 0
+ clear(d.referencedBitmap)
+ d.referencedIndices = d.referencedIndices[:0]
d.memo.Reset()
if d.preservedDict != nil {
d.preservedDict.Release()
@@ -147,6 +152,38 @@ func (d *dictEncoder) Reset() {
}
}
+func (d *dictEncoder) EnableDictionaryReferenceTracking() {
+ d.trackReferences = true
+}
+
+func (d *dictEncoder) ReferencedDictionaryIndices() []int32 {
+ return d.referencedIndices
+}
+
+func (d *dictEncoder) DictionaryIndexReferenced(index int) bool {
+ if index < 0 || index>>3 >= len(d.referencedBitmap) {
+ return false
+ }
+ return d.referencedBitmap[index>>3]&(1<<uint(index&7)) != 0
+}
+
+func (d *dictEncoder) recordDictionaryReference(index int32) {
+ if !d.trackReferences || index < 0 {
+ return
+ }
+
+ byteIndex := int(index) >> 3
+ if byteIndex >= len(d.referencedBitmap) {
+ d.referencedBitmap = append(d.referencedBitmap,
+ make([]byte, byteIndex-len(d.referencedBitmap)+1)...)
+ }
+ mask := byte(1 << uint(index&7))
+ if d.referencedBitmap[byteIndex]&mask == 0 {
+ d.referencedBitmap[byteIndex] |= mask
+ d.referencedIndices = append(d.referencedIndices, index)
+ }
+}
+
// ObservedRawSize returns the number of raw input bytes accumulated since
// the last data page flush. Used with DictEncodedSize and
// EstimatedDataEncodedSize to evaluate whether dictionary encoding is
@@ -182,56 +219,115 @@ func (d *dictEncoder) expandBuffer(newCap int) {
d.idxValues = arrow.Int32Traits.CastFromBytes(d.idxBuffer.Buf())[:
curLen : d.idxBuffer.Len()/arrow.Int32SizeBytes]
}
+type signedDictionaryIndex interface {
+ int8 | int16 | int32 | int64
+}
+
+type unsignedDictionaryIndex interface {
+ uint8 | uint16 | uint32 | uint64
+}
+
+const maxDictionaryIndex = uint64(1<<31 - 1)
+
+func (d *dictEncoder) invalidDictionaryIndex(index any) error {
+ return fmt.Errorf("%w: dictionary index %v out of bounds for dictionary
of length %d",
+ arrow.ErrInvalid, index, d.NumEntries())
+}
+
+func putSignedDictionaryIndices[T signedDictionaryIndex](d *dictEncoder, data
arrow.Array, values []T, start int) error {
+ dictSize := uint64(d.NumEntries())
+ curPos := start
+ return bitutils.VisitSetBitRuns(data.NullBitmapBytes(),
+ int64(data.Data().Offset()), int64(data.Len()),
+ func(pos, length int64) error {
+ for i := int64(0); i < length; i++ {
+ index := values[i+pos]
+ if index < 0 || uint64(index) >= dictSize ||
uint64(index) > maxDictionaryIndex {
+ return d.invalidDictionaryIndex(index)
+ }
+ d.idxValues[curPos] = int32(index)
+ d.recordDictionaryReference(int32(index))
+ curPos++
+ }
+ return nil
+ })
+}
+
+func putUnsignedDictionaryIndices[T unsignedDictionaryIndex](d *dictEncoder,
data arrow.Array, values []T, start int) error {
+ dictSize := uint64(d.NumEntries())
+ curPos := start
+ return bitutils.VisitSetBitRuns(data.NullBitmapBytes(),
+ int64(data.Data().Offset()), int64(data.Len()),
+ func(pos, length int64) error {
+ for i := int64(0); i < length; i++ {
+ index := values[i+pos]
+ if uint64(index) >= dictSize || uint64(index) >
maxDictionaryIndex {
+ return d.invalidDictionaryIndex(index)
+ }
+ d.idxValues[curPos] = int32(index)
+ d.recordDictionaryReference(int32(index))
+ curPos++
+ }
+ return nil
+ })
+}
+
+func (d *dictEncoder) rollbackDictionaryReferences(start, bitmapLen int) {
+ for _, index := range d.referencedIndices[start:] {
+ d.referencedBitmap[index>>3] &^= byte(1 << uint(index&7))
+ }
+ d.referencedIndices = d.referencedIndices[:start]
+ d.referencedBitmap = d.referencedBitmap[:bitmapLen]
+}
+
func (d *dictEncoder) PutIndices(data arrow.Array) error {
+ switch data.DataType().ID() {
+ case arrow.INT8, arrow.UINT8, arrow.INT16, arrow.UINT16,
+ arrow.INT32, arrow.UINT32, arrow.INT64, arrow.UINT64:
+ default:
+ return fmt.Errorf("%w: passed non-integer array to PutIndices",
arrow.ErrInvalid)
+ }
+
newValues := data.Len() - data.NullN()
curPos := len(d.idxValues)
newLen := newValues + curPos
d.expandBuffer(newLen)
d.idxValues = d.idxValues[:newLen:cap(d.idxValues)]
+ referenceStart := len(d.referencedIndices)
+ bitmapLen := len(d.referencedBitmap)
+ valueOffset := data.Data().Offset()
+ var err error
switch data.DataType().ID() {
- case arrow.UINT8, arrow.INT8:
- values :=
arrow.Uint8Traits.CastFromBytes(data.Data().Buffers()[1].Bytes())[data.Data().Offset():]
- bitutils.VisitSetBitRunsNoErr(data.NullBitmapBytes(),
- int64(data.Data().Offset()), int64(data.Len()),
- func(pos, length int64) {
- for i := int64(0); i < length; i++ {
- d.idxValues[curPos] =
int32(values[i+pos])
- curPos++
- }
- })
- case arrow.UINT16, arrow.INT16:
- values :=
arrow.Uint16Traits.CastFromBytes(data.Data().Buffers()[1].Bytes())[data.Data().Offset():]
- bitutils.VisitSetBitRunsNoErr(data.NullBitmapBytes(),
- int64(data.Data().Offset()), int64(data.Len()),
- func(pos, length int64) {
- for i := int64(0); i < length; i++ {
- d.idxValues[curPos] =
int32(values[i+pos])
- curPos++
- }
- })
- case arrow.UINT32, arrow.INT32:
- values :=
arrow.Uint32Traits.CastFromBytes(data.Data().Buffers()[1].Bytes())[data.Data().Offset():]
- bitutils.VisitSetBitRunsNoErr(data.NullBitmapBytes(),
- int64(data.Data().Offset()), int64(data.Len()),
- func(pos, length int64) {
- for i := int64(0); i < length; i++ {
- d.idxValues[curPos] =
int32(values[i+pos])
- curPos++
- }
- })
- case arrow.UINT64, arrow.INT64:
- values :=
arrow.Uint64Traits.CastFromBytes(data.Data().Buffers()[1].Bytes())[data.Data().Offset():]
- bitutils.VisitSetBitRunsNoErr(data.NullBitmapBytes(),
- int64(data.Data().Offset()), int64(data.Len()),
- func(pos, length int64) {
- for i := int64(0); i < length; i++ {
- d.idxValues[curPos] =
int32(values[i+pos])
- curPos++
- }
- })
- default:
- return fmt.Errorf("%w: passed non-integer array to PutIndices",
arrow.ErrInvalid)
+ case arrow.INT8:
+ values :=
arrow.Int8Traits.CastFromBytes(data.Data().Buffers()[1].Bytes())[valueOffset:]
+ err = putSignedDictionaryIndices(d, data, values, curPos)
+ case arrow.UINT8:
+ values :=
arrow.Uint8Traits.CastFromBytes(data.Data().Buffers()[1].Bytes())[valueOffset:]
+ err = putUnsignedDictionaryIndices(d, data, values, curPos)
+ case arrow.INT16:
+ values :=
arrow.Int16Traits.CastFromBytes(data.Data().Buffers()[1].Bytes())[valueOffset:]
+ err = putSignedDictionaryIndices(d, data, values, curPos)
+ case arrow.UINT16:
+ values :=
arrow.Uint16Traits.CastFromBytes(data.Data().Buffers()[1].Bytes())[valueOffset:]
+ err = putUnsignedDictionaryIndices(d, data, values, curPos)
+ case arrow.INT32:
+ values :=
arrow.Int32Traits.CastFromBytes(data.Data().Buffers()[1].Bytes())[valueOffset:]
+ err = putSignedDictionaryIndices(d, data, values, curPos)
+ case arrow.UINT32:
+ values :=
arrow.Uint32Traits.CastFromBytes(data.Data().Buffers()[1].Bytes())[valueOffset:]
+ err = putUnsignedDictionaryIndices(d, data, values, curPos)
+ case arrow.INT64:
+ values :=
arrow.Int64Traits.CastFromBytes(data.Data().Buffers()[1].Bytes())[valueOffset:]
+ err = putSignedDictionaryIndices(d, data, values, curPos)
+ case arrow.UINT64:
+ values :=
arrow.Uint64Traits.CastFromBytes(data.Data().Buffers()[1].Bytes())[valueOffset:]
+ err = putUnsignedDictionaryIndices(d, data, values, curPos)
+ }
+ if err != nil {
+ d.idxValues = d.idxValues[:curPos]
+ d.rollbackDictionaryReferences(referenceStart, bitmapLen)
+ return err
}
return nil
@@ -242,6 +338,7 @@ func (d *dictEncoder) addIndex(idx int) {
curLen := len(d.idxValues)
d.expandBuffer(curLen + 1)
d.idxValues = append(d.idxValues, int32(idx))
+ d.recordDictionaryReference(int32(idx))
}
// FlushValues dumps all the currently buffered indexes that would become the
data page to a buffer and
diff --git a/parquet/internal/encoding/typed_encoder.go
b/parquet/internal/encoding/typed_encoder.go
index 79b887a3..d547137a 100644
--- a/parquet/internal/encoding/typed_encoder.go
+++ b/parquet/internal/encoding/typed_encoder.go
@@ -168,6 +168,7 @@ func (enc *typedDictEncoder[T]) Put(in []T) {
enc.dictEncodedSize += int(unsafe.Sizeof(T(0)))
}
enc.idxValues[curPos+i] = int32(memoIdx)
+ enc.recordDictionaryReference(int32(memoIdx))
}
enc.AddRawSize(int64(len(in)) * int64(unsafe.Sizeof(T(0))))
}
@@ -458,7 +459,7 @@ func (enc *DictInt96Encoder) WriteDict(out []byte) {
// Put encodes the values passed in, adding to the index as needed
func (enc *DictInt96Encoder) Put(in []parquet.Int96) {
for _, v := range in {
- memoIdx, found, err := enc.memo.GetOrInsert(v)
+ memoIdx, found, err := enc.memo.GetOrInsert(v[:])
if err != nil {
panic(err)
}
@@ -487,6 +488,10 @@ func (enc *DictInt96Encoder) PutDictionary(arrow.Array)
error {
return fmt.Errorf("%w: direct PutDictionary to Int96",
arrow.ErrNotImplemented)
}
+func (enc *DictInt96Encoder) NormalizeDict(arrow.Array) (arrow.Array, error) {
+ return nil, fmt.Errorf("%w: direct PutDictionary to Int96",
arrow.ErrNotImplemented)
+}
+
// FallBackTo drains buffered indices through the dictionary into the
// fallback plain encoder and clears the index buffer.
func (enc *DictInt96Encoder) FallBackTo(fallback TypedEncoder) error {
diff --git a/parquet/internal/encoding/typed_encoder_test.go
b/parquet/internal/encoding/typed_encoder_test.go
index b9af99cb..d20b5d63 100644
--- a/parquet/internal/encoding/typed_encoder_test.go
+++ b/parquet/internal/encoding/typed_encoder_test.go
@@ -17,6 +17,8 @@
package encoding
import (
+ "math"
+ "strings"
"testing"
"unsafe"
@@ -26,6 +28,7 @@ import (
"github.com/apache/arrow-go/v18/parquet"
"github.com/apache/arrow-go/v18/parquet/schema"
"github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
)
func assertTypedDictEncoderPut[T int32 | int64 | float32 | float64](t
*testing.T, values, expectedDict []T, expectedIndices []int32) {
@@ -87,3 +90,98 @@ func TestPutDictionary(t *testing.T) {
err := enc.PutDictionary(arr)
assert.NoError(t, err)
}
+
+func TestDictionaryReferenceTracking(t *testing.T) {
+ dictionary, _, err := array.FromJSON(memory.DefaultAllocator,
arrow.PrimitiveTypes.Int32,
+ strings.NewReader(`[10, 20, 30]`))
+ require.NoError(t, err)
+ defer dictionary.Release()
+
+ indices, _, err := array.FromJSON(memory.DefaultAllocator,
arrow.PrimitiveTypes.Int32,
+ strings.NewReader(`[2, null, 2, 1]`))
+ require.NoError(t, err)
+ defer indices.Release()
+
+ typ := schema.NewInt32Node("a", parquet.Repetitions.Required, -1)
+ descr := schema.NewColumn(typ, 0, 0)
+ enc := &typedDictEncoder[int32]{newDictEncoderBase(descr,
NewDictionary[int32](), memory.DefaultAllocator)}
+ defer enc.Release()
+ enc.EnableDictionaryReferenceTracking()
+
+ require.NoError(t, enc.PutDictionary(dictionary))
+ require.NoError(t, enc.PutIndices(indices))
+ assert.Equal(t, []int32{2, 1}, enc.ReferencedDictionaryIndices())
+ assert.False(t, enc.DictionaryIndexReferenced(0))
+ assert.True(t, enc.DictionaryIndexReferenced(1))
+ assert.True(t, enc.DictionaryIndexReferenced(2))
+}
+
+func TestPutIndicesRejectsOutOfBoundsIndices(t *testing.T) {
+ dictionary, _, err := array.FromJSON(memory.DefaultAllocator,
arrow.PrimitiveTypes.Int32,
+ strings.NewReader(`[10]`))
+ require.NoError(t, err)
+ defer dictionary.Release()
+
+ tests := []struct {
+ name string
+ newIndices func() arrow.Array
+ }{
+ {
+ name: "negative signed index",
+ newIndices: func() arrow.Array {
+ builder :=
array.NewInt8Builder(memory.DefaultAllocator)
+ defer builder.Release()
+ builder.Append(-1)
+ return builder.NewArray()
+ },
+ },
+ {
+ name: "index equal to dictionary length",
+ newIndices: func() arrow.Array {
+ builder :=
array.NewInt16Builder(memory.DefaultAllocator)
+ defer builder.Release()
+ builder.Append(0)
+ builder.Append(1)
+ return builder.NewArray()
+ },
+ },
+ {
+ name: "maximum int32 index",
+ newIndices: func() arrow.Array {
+ builder :=
array.NewInt32Builder(memory.DefaultAllocator)
+ defer builder.Release()
+ builder.Append(math.MaxInt32)
+ return builder.NewArray()
+ },
+ },
+ {
+ name: "large unsigned index",
+ newIndices: func() arrow.Array {
+ builder :=
array.NewUint64Builder(memory.DefaultAllocator)
+ defer builder.Release()
+ builder.Append(math.MaxUint64)
+ return builder.NewArray()
+ },
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ indices := tt.newIndices()
+ defer indices.Release()
+
+ typ := schema.NewInt32Node("a",
parquet.Repetitions.Required, -1)
+ descr := schema.NewColumn(typ, 0, 0)
+ enc :=
&typedDictEncoder[int32]{newDictEncoderBase(descr, NewDictionary[int32](),
memory.DefaultAllocator)}
+ defer enc.Release()
+ enc.EnableDictionaryReferenceTracking()
+ require.NoError(t, enc.PutDictionary(dictionary))
+
+ err := enc.PutIndices(indices)
+ require.ErrorIs(t, err, arrow.ErrInvalid)
+ assert.Empty(t, enc.idxValues)
+ assert.Empty(t, enc.referencedBitmap)
+ assert.Empty(t, enc.ReferencedDictionaryIndices())
+ })
+ }
+}
diff --git a/parquet/internal/encoding/types.go
b/parquet/internal/encoding/types.go
index afbda0d6..9c940f6b 100644
--- a/parquet/internal/encoding/types.go
+++ b/parquet/internal/encoding/types.go
@@ -84,6 +84,16 @@ type TypedEncoder interface {
// encoding.
type DictEncoder interface {
TypedEncoder
+ // EnableDictionaryReferenceTracking records which dictionary entries
are
+ // referenced by encoded values. Tracking is disabled by default so
writers
+ // that do not need it do not pay its per-value cost.
+ EnableDictionaryReferenceTracking()
+ // ReferencedDictionaryIndices returns dictionary indices in the order
they
+ // were first referenced. The returned slice is owned by the encoder
and must
+ // not be modified.
+ ReferencedDictionaryIndices() []int32
+ // DictionaryIndexReferenced reports whether an entry has been
referenced.
+ DictionaryIndexReferenced(index int) bool
// WriteIndices populates the byte slice with the final indexes of data
and returns
// the number of bytes written
WriteIndices(out []byte) (int, error)
@@ -109,9 +119,8 @@ type DictEncoder interface {
// from PutDictionary or nil.
PreservedDictionary() arrow.Array
// PutIndices adds the indices from the passed in integral array to
- // the column data. It is assumed that the indices are within the bounds
- // of [0,dictSize) and is not validated. Returns an error if a
non-integral
- // array is passed.
+ // the column data. Returns an error if an index is outside [0,
dictSize)
+ // or a non-integral array is passed.
PutIndices(arrow.Array) error
// NormalizeDict takes an arrow array and normalizes it to a parquet
// native type. e.g. a dictionary of type int8 will be cast to an int32
diff --git a/parquet/metadata/bloom_filter.go b/parquet/metadata/bloom_filter.go
index 100786e1..16ded8b2 100644
--- a/parquet/metadata/bloom_filter.go
+++ b/parquet/metadata/bloom_filter.go
@@ -17,6 +17,7 @@
package metadata
import (
+ "encoding/binary"
"errors"
"fmt"
"io"
@@ -26,6 +27,7 @@ import (
"github.com/apache/arrow-go/v18/arrow"
"github.com/apache/arrow-go/v18/arrow/bitutil"
+ "github.com/apache/arrow-go/v18/arrow/endian"
"github.com/apache/arrow-go/v18/arrow/memory"
"github.com/apache/arrow-go/v18/internal/bitutils"
"github.com/apache/arrow-go/v18/parquet"
@@ -194,6 +196,30 @@ func GetSpacedHashesFromBitmap(h Hasher, numValid int64,
bitmap []byte, bitmapOf
func getBytes[T parquet.ColumnTypes](v T) []byte {
switch v := any(v).(type) {
+ case int32:
+ if endian.IsBigEndian {
+ var out [arrow.Int32SizeBytes]byte
+ binary.LittleEndian.PutUint32(out[:], uint32(v))
+ return out[:]
+ }
+ case int64:
+ if endian.IsBigEndian {
+ var out [arrow.Int64SizeBytes]byte
+ binary.LittleEndian.PutUint64(out[:], uint64(v))
+ return out[:]
+ }
+ case float32:
+ if endian.IsBigEndian {
+ var out [arrow.Float32SizeBytes]byte
+ binary.LittleEndian.PutUint32(out[:],
math.Float32bits(v))
+ return out[:]
+ }
+ case float64:
+ if endian.IsBigEndian {
+ var out [arrow.Float64SizeBytes]byte
+ binary.LittleEndian.PutUint64(out[:],
math.Float64bits(v))
+ return out[:]
+ }
case parquet.ByteArray:
return v
case parquet.FixedLenByteArray:
@@ -208,6 +234,46 @@ func getBytes[T parquet.ColumnTypes](v T) []byte {
func getBytesSlice[T parquet.ColumnTypes](v []T) [][]byte {
b := make([][]byte, len(v))
switch v := any(v).(type) {
+ case []int32:
+ if endian.IsBigEndian {
+ raw := make([]byte, arrow.Int32SizeBytes*len(v))
+ for i, vv := range v {
+ value := raw[i*arrow.Int32SizeBytes :
(i+1)*arrow.Int32SizeBytes]
+ binary.LittleEndian.PutUint32(value, uint32(vv))
+ b[i] = value
+ }
+ return b
+ }
+ case []int64:
+ if endian.IsBigEndian {
+ raw := make([]byte, arrow.Int64SizeBytes*len(v))
+ for i, vv := range v {
+ value := raw[i*arrow.Int64SizeBytes :
(i+1)*arrow.Int64SizeBytes]
+ binary.LittleEndian.PutUint64(value, uint64(vv))
+ b[i] = value
+ }
+ return b
+ }
+ case []float32:
+ if endian.IsBigEndian {
+ raw := make([]byte, arrow.Float32SizeBytes*len(v))
+ for i, vv := range v {
+ value := raw[i*arrow.Float32SizeBytes :
(i+1)*arrow.Float32SizeBytes]
+ binary.LittleEndian.PutUint32(value,
math.Float32bits(vv))
+ b[i] = value
+ }
+ return b
+ }
+ case []float64:
+ if endian.IsBigEndian {
+ raw := make([]byte, arrow.Float64SizeBytes*len(v))
+ for i, vv := range v {
+ value := raw[i*arrow.Float64SizeBytes :
(i+1)*arrow.Float64SizeBytes]
+ binary.LittleEndian.PutUint64(value,
math.Float64bits(vv))
+ b[i] = value
+ }
+ return b
+ }
case []parquet.ByteArray:
for i, vv := range v {
b[i] = vv
diff --git a/parquet/metadata/bloom_filter_test.go
b/parquet/metadata/bloom_filter_test.go
index 4206ec56..720de535 100644
--- a/parquet/metadata/bloom_filter_test.go
+++ b/parquet/metadata/bloom_filter_test.go
@@ -17,6 +17,7 @@
package metadata
import (
+ "bytes"
"fmt"
"math"
"math/rand/v2"
@@ -29,9 +30,37 @@ import (
"github.com/apache/arrow-go/v18/arrow/memory"
"github.com/apache/arrow-go/v18/parquet"
"github.com/apache/arrow-go/v18/parquet/schema"
+ "github.com/cespare/xxhash/v2"
"github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
)
+type recordingHasher struct {
+ inputs [][]byte
+}
+
+func (h *recordingHasher) Sum64(value []byte) uint64 {
+ h.inputs = append(h.inputs, bytes.Clone(value))
+ return xxhash.Sum64(value)
+}
+
+func (h *recordingHasher) Sum64s(values [][]byte) []uint64 {
+ hashes := make([]uint64, len(values))
+ for i, value := range values {
+ hashes[i] = h.Sum64(value)
+ }
+ return hashes
+}
+
+type expectedHashBloomFilter struct {
+ hasher Hasher
+ expected uint64
+}
+
+func (b expectedHashBloomFilter) Hasher() Hasher { return b.hasher
}
+func (b expectedHashBloomFilter) CheckHash(hash uint64) bool { return hash ==
b.expected }
+func (expectedHashBloomFilter) Size() int64 { return 0 }
+
func TestSplitBlockFilter(t *testing.T) {
const N = 1000
const S = 3
@@ -114,6 +143,38 @@ func TestGetHashes(t *testing.T) {
testHash(t, h, valsI32)
}
+func assertPlainEncodedBloomHash[T parquet.ColumnTypes](t *testing.T, value T,
expected []byte) {
+ t.Helper()
+
+ expectedHash := xxhash.Sum64(expected)
+ hasher := &recordingHasher{}
+ assert.Equal(t, expectedHash, GetHash[T](hasher, value))
+ require.Equal(t, [][]byte{expected}, hasher.inputs)
+
+ hasher.inputs = nil
+ assert.Equal(t, []uint64{expectedHash}, GetHashes(hasher, []T{value}))
+ require.Equal(t, [][]byte{expected}, hasher.inputs)
+
+ hasher.inputs = nil
+ assert.Equal(t, []uint64{expectedHash}, GetSpacedHashes(hasher, 1,
[]T{value}, []byte{1}, 0))
+ require.Equal(t, [][]byte{expected}, hasher.inputs)
+
+ hasher.inputs = nil
+ filter := TypedBloomFilter[T]{BloomFilter:
expectedHashBloomFilter{hasher: hasher, expected: expectedHash}}
+ assert.True(t, filter.Check(value))
+ require.Equal(t, [][]byte{expected}, hasher.inputs)
+}
+
+func TestBloomHashesUsePlainEncoding(t *testing.T) {
+ assertPlainEncodedBloomHash(t, int32(0x01020304), []byte{0x04, 0x03,
0x02, 0x01})
+ assertPlainEncodedBloomHash(t, int64(0x0102030405060708), []byte{0x08,
0x07, 0x06, 0x05, 0x04, 0x03, 0x02, 0x01})
+ assertPlainEncodedBloomHash(t, math.Float32frombits(0x01020304),
[]byte{0x04, 0x03, 0x02, 0x01})
+ assertPlainEncodedBloomHash(t,
math.Float64frombits(0x0102030405060708), []byte{0x08, 0x07, 0x06, 0x05, 0x04,
0x03, 0x02, 0x01})
+ assertPlainEncodedBloomHash(t, parquet.Int96{0, 1, 2, 3, 4, 5, 6, 7, 8,
9, 10, 11}, []byte{0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11})
+ assertPlainEncodedBloomHash(t, parquet.ByteArray("plain"),
[]byte("plain"))
+ assertPlainEncodedBloomHash(t, parquet.FixedLenByteArray("fixed"),
[]byte("fixed"))
+}
+
func TestNewBloomFilter(t *testing.T) {
tests := []struct {
ndv uint32
diff --git a/parquet/pqarrow/encode_dict_compute.go
b/parquet/pqarrow/encode_dict_compute.go
index 184ec0fa..be020c1d 100644
--- a/parquet/pqarrow/encode_dict_compute.go
+++ b/parquet/pqarrow/encode_dict_compute.go
@@ -113,7 +113,7 @@ func writeDictionaryArrow(ctx *arrowWriteContext, cw
file.ColumnChunkWriter, lea
if referencedIndices.Len() == normalized.Len() {
referencedDict = normalized
} else {
- referencedDict, err =
compute.TakeArrayOpts(ctx, normalized, referencedIndices,
compute.TakeOptions{BoundsCheck: false})
+ referencedDict, err =
compute.TakeArrayOpts(ctx, normalized, referencedIndices,
compute.TakeOptions{BoundsCheck: true})
if err != nil {
return err
}
diff --git a/parquet/pqarrow/encode_dictionary_test.go
b/parquet/pqarrow/encode_dictionary_test.go
index b027f7f0..7f9a0a09 100644
--- a/parquet/pqarrow/encode_dictionary_test.go
+++ b/parquet/pqarrow/encode_dictionary_test.go
@@ -34,6 +34,7 @@ import (
"github.com/apache/arrow-go/v18/parquet"
"github.com/apache/arrow-go/v18/parquet/file"
"github.com/apache/arrow-go/v18/parquet/internal/testutils"
+ "github.com/apache/arrow-go/v18/parquet/metadata"
"github.com/apache/arrow-go/v18/parquet/pqarrow"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
@@ -81,6 +82,150 @@ func TestWriteColumnChunkedPropagatesLevelBuilderError(t
*testing.T) {
require.NoError(t, writer.Close())
}
+func TestDictionaryArrayBloomFilter(t *testing.T) {
+ mem := memory.DefaultAllocator
+
+ dictionary, _, err := array.FromJSON(mem, arrow.BinaryTypes.String,
+ strings.NewReader(`["unused", "alpha", "beta"]`))
+ require.NoError(t, err)
+ defer dictionary.Release()
+ indices, _, err := array.FromJSON(mem, arrow.PrimitiveTypes.Int32,
+ strings.NewReader(`[1, null, 1, 2, 1]`))
+ require.NoError(t, err)
+ defer indices.Release()
+
+ dictType := &arrow.DictionaryType{IndexType:
arrow.PrimitiveTypes.Int32, ValueType: arrow.BinaryTypes.String}
+ values := array.NewDictionaryArray(dictType, indices, dictionary)
+ defer values.Release()
+ sc := arrow.NewSchema([]arrow.Field{{Name: "values", Type: dictType,
Nullable: true}}, nil)
+
+ var output bytes.Buffer
+ writer, err := pqarrow.NewFileWriter(sc, &output,
parquet.NewWriterProperties(
+ parquet.WithAllocator(mem),
+ parquet.WithDictionaryDefault(true),
+ parquet.WithStats(false),
+ parquet.WithDataPageSize(1),
+ parquet.WithBatchSize(2),
+ parquet.WithBloomFilterEnabledFor("values", true),
+ parquet.WithBloomFilterNDVFor("values", 2),
+ ), pqarrow.NewArrowWriterProperties(pqarrow.WithAllocator(mem)))
+ require.NoError(t, err)
+ require.NoError(t, writer.NewRowGroupChecked())
+ require.NoError(t, writer.WriteColumnData(values))
+ require.NoError(t, writer.Close())
+
+ reader, err := file.NewParquetReader(bytes.NewReader(output.Bytes()))
+ require.NoError(t, err)
+ defer reader.Close()
+ bloomReader := reader.GetBloomFilterReader()
+ rowGroupBloom, err := bloomReader.RowGroup(0)
+ require.NoError(t, err)
+ require.NoError(t, rowGroupBloom.VisitColumnBloomFilter(0, func(filter
metadata.BloomFilter) error {
+ typedFilter :=
metadata.TypedBloomFilter[parquet.ByteArray]{BloomFilter: filter}
+ assert.True(t, typedFilter.Check(parquet.ByteArray("alpha")))
+ assert.True(t, typedFilter.Check(parquet.ByteArray("beta")))
+ return nil
+ }))
+}
+
+func TestWriteColumnDataRejectsOutOfBoundsDictionaryIndex(t *testing.T) {
+ mem := memory.DefaultAllocator
+
+ dictionary, _, err := array.FromJSON(mem, arrow.PrimitiveTypes.Int32,
+ strings.NewReader(`[10, 20]`))
+ require.NoError(t, err)
+ defer dictionary.Release()
+ indices, _, err := array.FromJSON(mem, arrow.PrimitiveTypes.Int8,
+ strings.NewReader(`[2]`))
+ require.NoError(t, err)
+ defer indices.Release()
+
+ dictType := &arrow.DictionaryType{IndexType: arrow.PrimitiveTypes.Int8,
ValueType: arrow.PrimitiveTypes.Int32}
+ values := array.NewDictionaryArray(dictType, indices, dictionary)
+ defer values.Release()
+ sc := arrow.NewSchema([]arrow.Field{{Name: "values", Type: dictType,
Nullable: false}}, nil)
+
+ var output bytes.Buffer
+ writer, err := pqarrow.NewFileWriter(sc, &output,
parquet.NewWriterProperties(
+ parquet.WithAllocator(mem),
+ parquet.WithDictionaryDefault(true),
+ parquet.WithStats(true),
+ ), pqarrow.NewArrowWriterProperties(pqarrow.WithAllocator(mem)))
+ require.NoError(t, err)
+ require.NoError(t, writer.NewRowGroupChecked())
+
+ err = writer.WriteColumnData(values)
+ require.ErrorIs(t, err, arrow.ErrIndex)
+ require.NoError(t, writer.Close())
+}
+
+func BenchmarkDictionaryArrayBloomFilter(b *testing.B) {
+ tests := []struct {
+ name string
+ dictionaryEntries int
+ referencedEntries int
+ }{
+ {name: "dictionary=100k/referenced=10", dictionaryEntries:
100_000, referencedEntries: 10},
+ {name: "dictionary=1m/referenced=100", dictionaryEntries:
1_000_000, referencedEntries: 100},
+ }
+
+ for _, tt := range tests {
+ b.Run(tt.name, func(b *testing.B) {
+ const numRows = 100_000
+ mem := memory.DefaultAllocator
+
+ dictionaryBuilder := array.NewStringBuilder(mem)
+ dictionaryBuilder.Reserve(tt.dictionaryEntries)
+ for i := 0; i < tt.dictionaryEntries; i++ {
+
dictionaryBuilder.Append(fmt.Sprintf("value-%08d", i))
+ }
+ dictionary := dictionaryBuilder.NewArray()
+ dictionaryBuilder.Release()
+ defer dictionary.Release()
+
+ indicesBuilder := array.NewInt32Builder(mem)
+ indicesBuilder.Reserve(numRows)
+ for i := 0; i < numRows; i++ {
+ indicesBuilder.Append(int32(i %
tt.referencedEntries))
+ }
+ indices := indicesBuilder.NewArray()
+ indicesBuilder.Release()
+ defer indices.Release()
+
+ dictType := &arrow.DictionaryType{IndexType:
arrow.PrimitiveTypes.Int32, ValueType: arrow.BinaryTypes.String}
+ values := array.NewDictionaryArray(dictType, indices,
dictionary)
+ defer values.Release()
+ sc := arrow.NewSchema([]arrow.Field{{Name: "values",
Type: dictType}}, nil)
+ props := parquet.NewWriterProperties(
+ parquet.WithDictionaryDefault(true),
+ parquet.WithStats(false),
+ parquet.WithBloomFilterEnabledFor("values",
true),
+ parquet.WithBloomFilterNDVFor("values",
int64(tt.referencedEntries)),
+ )
+ arrowProps :=
pqarrow.NewArrowWriterProperties(pqarrow.WithAllocator(mem))
+
+ b.ReportAllocs()
+ b.ResetTimer()
+ for i := 0; i < b.N; i++ {
+ var output bytes.Buffer
+ writer, err := pqarrow.NewFileWriter(sc,
&output, props, arrowProps)
+ if err != nil {
+ b.Fatal(err)
+ }
+ if err := writer.NewRowGroupChecked(); err !=
nil {
+ b.Fatal(err)
+ }
+ if err := writer.WriteColumnData(values); err
!= nil {
+ b.Fatal(err)
+ }
+ if err := writer.Close(); err != nil {
+ b.Fatal(err)
+ }
+ }
+ })
+ }
+}
+
func (ps *ParquetIOTestSuite) TestSingleColumnOptionalDictionaryWrite() {
for _, dt := range fullTypeList {
// skip tests for bool as we don't do dictionaries for it