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/iceberg-go.git
The following commit(s) were added to refs/heads/main by this push:
new 0490c3f1a perf(table): cache dictionary paths for position deletes
(#2231)
0490c3f1a is described below
commit 0490c3f1aca665130413b311e98a6283ced81761
Author: Minh Vu <[email protected]>
AuthorDate: Tue Oct 20 20:21:57 2026 +0200
perf(table): cache dictionary paths for position deletes (#2231)
* perf(table): cache dictionary file paths during position delete grouping
* perf(table): keep uncached delete path lean
* style(table): fix lint findings
* fix(table): reject null dictionary file paths in position deletes
* test(table): cover null dictionary indices in position deletes
* test(table): expect column validation for null dictionary indices
---
table/arrow_scanner.go | 108 +++++++++++++++-
table/arrow_scanner_posdelete_cache_test.go | 186 ++++++++++++++++++++++++++++
2 files changed, 291 insertions(+), 3 deletions(-)
diff --git a/table/arrow_scanner.go b/table/arrow_scanner.go
index 3a75df002..f9ca7d6b2 100644
--- a/table/arrow_scanner.go
+++ b/table/arrow_scanner.go
@@ -712,6 +712,10 @@ func validatePosDeleteColumnLengths(filePathLen, posLen
int) error {
return nil
}
+// A position delete file usually references few data files. Do not reserve
+// memory proportional to an unusually large Arrow dictionary.
+const maxPositionDeleteDictionaryCacheEntries = 4096
+
func (a *posDeleteAccumulator) appendFilePathChunk(ctx context.Context,
filePathChunk arrow.Array,
posCursor *posDeleteCursor,
) error {
@@ -726,12 +730,30 @@ func (a *posDeleteAccumulator) appendFilePathChunk(ctx
context.Context, filePath
var dictionary arrow.Array
var indices *array.Dictionary
- if dict, ok := filePathChunk.(*array.Dictionary); ok &&
dict.Dictionary().NullN() > 0 {
- dictionary = dict.Dictionary()
+ if dict, ok := filePathChunk.(*array.Dictionary); ok {
indices = dict
+ if dict.Dictionary().NullN() > 0 {
+ dictionary = dict.Dictionary()
+ }
+ if size := dict.Dictionary().Len(); size > 0 && size <=
maxPositionDeleteDictionaryCacheEntries {
+ return a.appendFilePathChunkWithDictionaryCache(
+ ctx, paths, dictionary, indices, dict,
posCursor)
+ }
}
- for i := range filePathChunk.Len() {
+ return a.appendFilePathChunkWithoutCache(
+ ctx, filePathChunk.Len(), paths, dictionary, indices, posCursor)
+}
+
+func (a *posDeleteAccumulator) appendFilePathChunkWithoutCache(
+ ctx context.Context,
+ length int,
+ paths arrow.TypedArray[string],
+ dictionary arrow.Array,
+ indices *array.Dictionary,
+ posCursor *posDeleteCursor,
+) error {
+ for i := range length {
if i&(positionalDeleteCancellationCheckInterval-1) == 0 {
if err := ctx.Err(); err != nil {
return err
@@ -747,6 +769,10 @@ func (a *posDeleteAccumulator) appendFilePathChunk(ctx
context.Context, filePath
return fmt.Errorf("%w: negative pos %d in position
delete file",
iceberg.ErrInvalidSchema, pos)
}
+ if indices != nil && indices.IsNull(i) {
+ return fmt.Errorf("%w: null file_path dictionary index
in position delete file",
+ iceberg.ErrInvalidSchema)
+ }
if dictionary != nil &&
dictionary.IsNull(indices.GetValueIndex(i)) {
return fmt.Errorf("%w: null file_path dictionary value
in position delete file",
iceberg.ErrInvalidSchema)
@@ -771,6 +797,82 @@ func (a *posDeleteAccumulator) appendFilePathChunk(ctx
context.Context, filePath
return nil
}
+func (a *posDeleteAccumulator) appendFilePathChunkWithDictionaryCache(
+ ctx context.Context,
+ paths arrow.TypedArray[string],
+ dictionary arrow.Array,
+ indices *array.Dictionary,
+ cachedIndices *array.Dictionary,
+ posCursor *posDeleteCursor,
+) error {
+ cachedBuilders := make([]*array.Int64Builder,
cachedIndices.Dictionary().Len())
+ cachedSeen := make([]bool, len(cachedBuilders))
+
+ for i := range cachedIndices.Len() {
+ if i&(positionalDeleteCancellationCheckInterval-1) == 0 {
+ if err := ctx.Err(); err != nil {
+ return err
+ }
+ }
+
+ pos, ok := posCursor.next()
+ if !ok {
+ return fmt.Errorf("%w: position delete columns ended
before file_path column",
+ iceberg.ErrInvalidSchema)
+ }
+ if pos < 0 {
+ return fmt.Errorf("%w: negative pos %d in position
delete file",
+ iceberg.ErrInvalidSchema, pos)
+ }
+ if indices != nil && indices.IsNull(i) {
+ return fmt.Errorf("%w: null file_path dictionary index
in position delete file",
+ iceberg.ErrInvalidSchema)
+ }
+ if dictionary != nil &&
dictionary.IsNull(indices.GetValueIndex(i)) {
+ return fmt.Errorf("%w: null file_path dictionary value
in position delete file",
+ iceberg.ErrInvalidSchema)
+ }
+
+ // Resolve each dictionary index once per chunk. Several
indexes may
+ // refer to the same file path, so retain the existing
string-keyed
+ // builders as the source of truth.
+ cacheIndex := cachedIndices.GetValueIndex(i)
+ if cacheIndex >= 0 && cacheIndex < len(cachedSeen) &&
cachedSeen[cacheIndex] {
+ if builder := cachedBuilders[cacheIndex]; builder !=
nil {
+ builder.Append(pos)
+ }
+
+ continue
+ }
+
+ path := paths.Value(i)
+ if len(a.targets) > 0 {
+ if _, ok := a.targets[path]; !ok {
+ // A nil builder caches a rejected target as
well.
+ if cacheIndex >= 0 && cacheIndex <
len(cachedSeen) {
+ cachedSeen[cacheIndex] = true
+ }
+
+ continue
+ }
+ }
+
+ builder, ok := a.builders[path]
+ if !ok {
+ path = strings.Clone(path)
+ builder = array.NewInt64Builder(a.mem)
+ a.builders[path] = builder
+ }
+ if cacheIndex >= 0 && cacheIndex < len(cachedSeen) {
+ cachedSeen[cacheIndex] = true
+ cachedBuilders[cacheIndex] = builder
+ }
+ builder.Append(pos)
+ }
+
+ return nil
+}
+
func (a *posDeleteAccumulator) appendChunked(ctx context.Context, filePathCol,
posCol *arrow.Chunked) error {
if err := ctx.Err(); err != nil {
return err
diff --git a/table/arrow_scanner_posdelete_cache_test.go
b/table/arrow_scanner_posdelete_cache_test.go
new file mode 100644
index 000000000..ec9e287bc
--- /dev/null
+++ b/table/arrow_scanner_posdelete_cache_test.go
@@ -0,0 +1,186 @@
+// 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 table
+
+import (
+ "fmt"
+ "testing"
+
+ "github.com/apache/arrow-go/v18/arrow"
+ "github.com/apache/arrow-go/v18/arrow/array"
+ "github.com/apache/arrow-go/v18/arrow/compute"
+ "github.com/apache/arrow-go/v18/arrow/memory"
+ "github.com/stretchr/testify/require"
+)
+
+func TestPosDeleteDictionaryPathCache(t *testing.T) {
+ for _, dictionarySize := range []int{3,
maxPositionDeleteDictionaryCacheEntries + 1} {
+ t.Run(fmt.Sprintf("dictionary-entries=%d", dictionarySize),
func(t *testing.T) {
+ mem :=
memory.NewCheckedAllocator(memory.DefaultAllocator)
+ defer mem.AssertSize(t, 0)
+ ctx := compute.WithAllocator(t.Context(), mem)
+
+ pathNames := make([]string, dictionarySize)
+ for i := range pathNames {
+ pathNames[i] = fmt.Sprintf("other-%d.parquet",
i)
+ }
+ pathNames[0] = "file-a.parquet"
+ pathNames[1] = "file-b.parquet"
+ pathNames[2] = "file-a.parquet" // Different indexes
with the same value.
+
+ dictionary := stringArray(mem, pathNames...)
+ defer dictionary.Release()
+ indexes := int32Array(mem, 0, 0, 1, 2, 1, 2, 0)
+ defer indexes.Release()
+ dictType := &arrow.DictionaryType{
+ IndexType: arrow.PrimitiveTypes.Int32,
+ ValueType: arrow.BinaryTypes.String,
+ }
+ pathArray := array.NewDictionaryArray(dictType,
indexes, dictionary)
+ defer pathArray.Release()
+ pathCol := arrow.NewChunked(dictType,
[]arrow.Array{pathArray})
+ defer pathCol.Release()
+
+ posArray := int64Array(mem, 11, 12, 13, 14, 15, 16, 17)
+ defer posArray.Release()
+ posCol := arrow.NewChunked(arrow.PrimitiveTypes.Int64,
[]arrow.Array{posArray})
+ defer posCol.Release()
+
+ acc := newPosDeleteAccumulator(ctx,
map[string]struct{}{"file-a.parquet": {}})
+ defer acc.release()
+ require.NoError(t, acc.appendChunked(ctx, pathCol,
posCol))
+ grouped := acc.finish()
+ defer releasePosDeletes(grouped)
+ require.Len(t, grouped, 1)
+ require.Equal(t, []int64{11, 12, 14, 16, 17},
int64Values(grouped["file-a.parquet"]))
+ })
+ }
+}
+
+func TestPosDeleteDictionaryPathCacheValidatesEveryPosition(t *testing.T) {
+ mem := memory.NewCheckedAllocator(memory.DefaultAllocator)
+ defer mem.AssertSize(t, 0)
+ ctx := compute.WithAllocator(t.Context(), mem)
+
+ dictionary := stringArray(mem, "file-a.parquet")
+ defer dictionary.Release()
+ indexes := int32Array(mem, 0, 0)
+ defer indexes.Release()
+ dictType := &arrow.DictionaryType{
+ IndexType: arrow.PrimitiveTypes.Int32,
+ ValueType: arrow.BinaryTypes.String,
+ }
+ pathArray := array.NewDictionaryArray(dictType, indexes, dictionary)
+ defer pathArray.Release()
+ pathCol := arrow.NewChunked(dictType, []arrow.Array{pathArray})
+ defer pathCol.Release()
+
+ posArray := int64Array(mem, 1, -2)
+ defer posArray.Release()
+ posCol := arrow.NewChunked(arrow.PrimitiveTypes.Int64,
[]arrow.Array{posArray})
+ defer posCol.Release()
+
+ acc := newPosDeleteAccumulator(ctx, nil)
+ defer acc.release()
+ err := acc.appendChunked(ctx, pathCol, posCol)
+ require.ErrorContains(t, err, "negative pos -2")
+}
+
+func TestPosDeleteDictionaryPathCacheRejectsNullIndices(t *testing.T) {
+ for _, dictionarySize := range []int{1,
maxPositionDeleteDictionaryCacheEntries + 1} {
+ t.Run(fmt.Sprintf("dictionary-entries=%d", dictionarySize),
func(t *testing.T) {
+ mem :=
memory.NewCheckedAllocator(memory.DefaultAllocator)
+ defer mem.AssertSize(t, 0)
+ ctx := compute.WithAllocator(t.Context(), mem)
+
+ paths := make([]string, dictionarySize)
+ for i := range paths {
+ paths[i] = fmt.Sprintf("file-%d.parquet", i)
+ }
+ dictionary := stringArray(mem, paths...)
+ defer dictionary.Release()
+
+ indexBuilder := array.NewInt32Builder(mem)
+ indexBuilder.Append(0)
+ indexBuilder.AppendNull()
+ indexes := indexBuilder.NewArray()
+ indexBuilder.Release()
+ defer indexes.Release()
+
+ dictType := &arrow.DictionaryType{
+ IndexType: arrow.PrimitiveTypes.Int32,
+ ValueType: arrow.BinaryTypes.String,
+ }
+ pathArray := array.NewDictionaryArray(dictType,
indexes, dictionary)
+ defer pathArray.Release()
+ pathCol := arrow.NewChunked(dictType,
[]arrow.Array{pathArray})
+ defer pathCol.Release()
+
+ posArray := int64Array(mem, 1, 2)
+ defer posArray.Release()
+ posCol := arrow.NewChunked(arrow.PrimitiveTypes.Int64,
[]arrow.Array{posArray})
+ defer posCol.Release()
+
+ acc := newPosDeleteAccumulator(ctx, nil)
+ defer acc.release()
+ err := acc.appendChunked(ctx, pathCol, posCol)
+ // Column-level validation rejects null dictionary
indices before either cache path runs.
+ require.ErrorContains(t, err, "null file_path in
position delete file")
+ })
+ }
+}
+
+func BenchmarkGroupPosDeletesByDictionaryIndex(b *testing.B) {
+ const numRows = 262_144
+ for _, numPaths := range []int{1, 16, 512,
maxPositionDeleteDictionaryCacheEntries + 1} {
+ b.Run(fmt.Sprintf("paths=%d", numPaths), func(b *testing.B) {
+ mem := memory.DefaultAllocator
+ pathNames := make([]string, numPaths)
+ for i := range pathNames {
+ pathNames[i] =
fmt.Sprintf("s3://bucket/table/data/%05d.parquet", i)
+ }
+ dictionary := stringArray(mem, pathNames...)
+ defer dictionary.Release()
+
+ indexes := make([]int32, numRows)
+ positions := make([]int64, numRows)
+ for i := range indexes {
+ indexes[i] = int32(i % numPaths)
+ positions[i] = int64(i)
+ }
+ indexArray := int32Array(mem, indexes...)
+ defer indexArray.Release()
+ dictType := &arrow.DictionaryType{
+ IndexType: arrow.PrimitiveTypes.Int32,
+ ValueType: arrow.BinaryTypes.String,
+ }
+ pathArray := array.NewDictionaryArray(dictType,
indexArray, dictionary)
+ defer pathArray.Release()
+ pathCol := arrow.NewChunked(dictType,
[]arrow.Array{pathArray})
+ defer pathCol.Release()
+
+ posArray := int64Array(mem, positions...)
+ defer posArray.Release()
+ posCol := arrow.NewChunked(arrow.PrimitiveTypes.Int64,
[]arrow.Array{posArray})
+ defer posCol.Release()
+
+ ctx := compute.WithAllocator(b.Context(), mem)
+ benchmarkGroupPosDeletesByFilePath(b, ctx, pathCol,
posCol, groupPosDeletesByFilePath)
+ })
+ }
+}