Skip to content

Severe sync.Pool degradation and infinite heap allocations in GetColumnBloomFilter due to pool pollution and ResizeNoShrink(0) #863

Description

@Patzifist

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

The Problem
There is a massive, highly inefficient memory allocation overhead during parallel execution of GetColumnBloomFilter under high concurrency (e.g., streaming thousands of Parquet files via 65 parallel splits).

The root cause lies in a combination of two architectural design flaws in the Parquet reader:

  1. Pool Pollution (Variable Buffer Sizes): The reader uses a single, shared r.bufferPool (sync.Pool) for everything. It simultaneously recycles tiny page headers (re-sliced to 256 bytes or 4 KB) and heavy Bloom Filter bitsets (expanded to 2 MB) into the exact same pool.
  2. Capacity Destruction: Inside parquet/metadata/cleanup.go, the asynchronous runtime.AddCleanup task (or explicit defers) resets recycled buffers using data.ResizeNoShrink(0).

Reproducer Log / Evidence
We ran a concurrent stress test simulating a standard Trino/Iceberg split engine: 65 concurrent goroutines executing 100 sequential file reads each, processing variable-sized Bloom Filter segments (randomized between 512 KB and 1 MB to mimic real production data).

=== RUN   TestGetColumnBloomFilter_OriginalPoolLeak
    bloom_filter_leak_test.go:116: Original stress test processed 6500 operations in 1.396053644s

Cumulative Allocated Memory: 13024.81 MB
Total OS Mallocs Count:      254009

package metadata

import (
	"bytes"
	"context"
	"fmt"
	"math/rand"
	"runtime"
	"sync"
	"testing"
	"time"

	"github.com/apache/arrow-go/v18/arrow/memory"
	"github.com/apache/arrow-go/v18/parquet"
	format "github.com/apache/arrow-go/v18/parquet/internal/gen-go/parquet"
	"github.com/apache/arrow-go/v18/parquet/internal/thrift"
	"github.com/apache/arrow-go/v18/parquet/schema"
)

type fakeReader struct {
	*bytes.Reader
}

func (f *fakeReader) ReadAt(p []byte, off int64) (int, error) {
	return f.Reader.ReadAt(p, off)
}

func TestGetColumnBloomFilter_OriginalPoolLeak(t *testing.T) {
	ctx := context.Background()

	maxSize := 2 * 1024 * 1024

	originalBufferPool := &sync.Pool{
		New: func() any {
			buf := memory.NewResizableBuffer(memory.NewGoAllocator())
			runtime.SetFinalizer(buf, func(obj *memory.Buffer) {
				obj.Release()
			})
			return buf
		},
	}

	runtime.GC()
	var memStart runtime.MemStats
	runtime.ReadMemStats(&memStart)

	const (
		goroutines          = 65
		iterationsPerWorker = 100
	)

	var wg sync.WaitGroup
	startSignal := make(chan struct{})

	for range goroutines {
		wg.Add(1)
		go func() {
			defer wg.Done()
			<-startSignal

			for range iterationsPerWorker {
				bloomFilterDataSize := int32(512*1024 + rand.Intn(512*1024))
				bloomFilterReadSize := int32(4096)

				header := format.BloomFilterHeader{
					NumBytes:    bloomFilterDataSize,
					Algorithm:   &defaultAlgorithm,
					Hash:        &defaultHashStrategy,
					Compression: &defaultCompression,
				}

				serializer := thrift.NewThriftSerializer()
				headerBytes, _ := serializer.Write(ctx, &header)

				localFileData := make([]byte, maxSize)
				copy(localFileData, headerBytes)

				var offset int64 = 0
				columnMetaData := format.ColumnMetaData{
					Type:              format.Type_BYTE_ARRAY,
					PathInSchema:      []string{"test_col"},
					Codec:             format.CompressionCodec_UNCOMPRESSED,
					BloomFilterOffset: &offset,
					BloomFilterLength: &bloomFilterReadSize,
				}
				thriftColumnChunk := format.ColumnChunk{MetaData: &columnMetaData}
				thriftRowGroup := format.RowGroup{Columns: []*format.ColumnChunk{&thriftColumnChunk}}

				node, _ := schema.NewPrimitiveNode("test_col", parquet.Repetition(format.FieldRepetitionType_REQUIRED), parquet.Type(format.Type_BYTE_ARRAY), -1, -1)
				rootGroup, _ := schema.NewGroupNode("schema", parquet.Repetition(format.FieldRepetitionType_REPEATED), schema.FieldList{node}, -1)
				sc := schema.NewSchema(rootGroup)

				threadRdr := &RowGroupBloomFilterReader{
					input:          &fakeReader{Reader: bytes.NewReader(localFileData)},
					sourceFileSize: int64(len(localFileData)),
					bufferPool:     originalBufferPool,
					rgMeta:         NewRowGroupMetaData(&thriftRowGroup, sc, nil, nil),
				}

				bf, err := threadRdr.GetColumnBloomFilter(0)
				if err != nil {
					continue
				}

				if b, ok := bf.(*blockSplitBloomFilter); ok && b.data != nil {
					b.data.ResizeNoShrink(0)
					originalBufferPool.Put(b.data)
				}
			}
		}()
	}

	startTime := time.Now()
	close(startSignal)
	wg.Wait()
	t.Logf("Original stress test processed 6500 operations in %v", time.Since(startTime))

	var memEnd runtime.MemStats
	runtime.ReadMemStats(&memEnd)

	totalAllocatedMB := float64(memEnd.TotalAlloc-memStart.TotalAlloc) / 1024 / 1024
	heapInuseMB := float64(memEnd.HeapInuse-memStart.HeapInuse) / 1024 / 1024

	fmt.Printf("Cumulative Allocated Memory: %.2f MB\n", totalAllocatedMB)
	fmt.Printf("Active Heap In Use Memory:   %.2f MB\n", heapInuseMB)
	fmt.Printf("Total OS Mallocs Count:      %d\n\n", memEnd.Mallocs-memStart.Mallocs)

	const MaxAllowedAllocMB = 500.0
	if totalAllocatedMB > MaxAllowedAllocMB {
		t.Errorf("FAIL DETECTED: sync.Pool memory leak verified! Allocated %.2f MB", totalAllocatedMB)
	}
}

Component(s)

Parquet

Metadata

Metadata

Assignees

No one assigned

    Labels

    Type: bugSomething isn't working

    Type

    No type

    Fields

    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions